Skip to content

Commit 6d2c799

Browse files
authored
refactor(streak): introduced zod schema for API parameter validation (JhaSourav07#124)
## Description Fixes JhaSourav07#67 Introduces zod for strict validation of incoming URL parameters across all three API routes, replacing scattered manual `searchParams.get()` calls and if/else fallbacks with a single centralized schema. - New file `lib/validations.ts`: Defines `streakParamsSchema`, `githubParamsSchema`, and `ogParamsSchema` using `Zod`. Default values (theme: "dark", scale: "linear", speed: "8s") are handled via Zod's `.default()`. Invalid speed and scale values silently fall back to their defaults using `.catch()` to preserve existing behavior. The user and username fields are required and return a structured JSON error if missing. - Changes in `app/api/streak/route.ts`: Replaced all individual `searchParams.get()` calls with a single `streakParamsSchema.safeParse()` call at the top of the handler. If parsing fails (e.g. missing user), the route returns a clean structured JSON 400 error immediately before any GitHub API call is made. All downstream logic remains identical. - Changes in `app/api/github/route.ts`: Replaced manual `searchParams.get('username')` and `if (!username)` check with `githubParamsSchema.safeParse()`. Returns a structured JSON 400 error if username is missing. All error handling and cache logic remains identical. - Changes in `app/api/og/route.tsx`: Replaced `searchParams.get('user')` ?? 'unknown' with `ogParamsSchema.parse()`. Since user is optional with a default of 'unknown', this route never throws a validation error. All rendering logic remains identical. - Added 4 additional optional properties — `radius`, `font`, `year`, and `refresh` {Other than the requested ones}. - All Acceptance Criteria are met. 🚀 ## Pillar - 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview <img width="457" height="325" alt="Screenshot 2026-05-17 at 1 43 52 PM" src="https://github.com/user-attachments/assets/8f46c743-60c3-4d9d-98fa-57a160c089d9" /> ## Checklist before requesting a review: - I have read the `CONTRIBUTING.md` file. - I have tested these changes locally (`localhost:3000/api/streak`). - I have run `npm run format` and `npm run lint` locally and resolved all errors. - My commits follow the Conventional Commits format. - I have started the repo. - I have made sure that i have only one commit to merge in this PR. - The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts).
2 parents fc12b05 + 710a114 commit 6d2c799

6 files changed

Lines changed: 98 additions & 121 deletions

File tree

app/api/github/route.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { NextResponse } from 'next/server';
22
import { getFullDashboardData } from '@/lib/github';
3+
import { githubParamsSchema } from '../../../lib/validations';
34

45
export async function GET(request: Request) {
56
const { searchParams } = new URL(request.url);
6-
const username = searchParams.get('username');
7-
const refresh = searchParams.get('refresh') === 'true';
87

9-
if (!username) {
10-
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
8+
const parseResult = githubParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
9+
10+
if (!parseResult.success) {
11+
return NextResponse.json(
12+
{ error: 'Invalid parameters', details: parseResult.error.flatten() },
13+
{ status: 400 }
14+
);
1115
}
1216

17+
const { username, refresh } = parseResult.data;
18+
1319
try {
1420
const data = await getFullDashboardData(username, { bypassCache: refresh });
1521
const cacheControl = refresh

app/api/og/route.tsx

Lines changed: 13 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { ImageResponse } from 'next/og';
22
import { NextRequest } from 'next/server';
3+
import { ogParamsSchema } from '../../../lib/validations';
34

45
export const runtime = 'edge';
56

67
export async function GET(req: NextRequest) {
78
const { searchParams } = new URL(req.url);
8-
const user = searchParams.get('user') ?? 'unknown';
9+
10+
const { user } = ogParamsSchema.parse(Object.fromEntries(searchParams.entries()));
911

1012
let totalCommits = 0;
1113
let longestStreak = 0;
@@ -57,34 +59,11 @@ export async function GET(req: NextRequest) {
5759
left: '300px',
5860
}}
5961
/>
60-
61-
<div
62-
style={{
63-
fontSize: '48px',
64-
color: '#58a6ff',
65-
fontWeight: 'bold',
66-
marginBottom: '24px',
67-
}}
68-
>
62+
<div style={{ fontSize: '48px', color: '#58a6ff', fontWeight: 'bold', marginBottom: '24px' }}>
6963
⚡ CommitPulse
7064
</div>
71-
72-
<div
73-
style={{
74-
fontSize: '32px',
75-
color: '#c9d1d9',
76-
marginBottom: '48px',
77-
}}
78-
>
79-
@{user}
80-
</div>
81-
82-
<div
83-
style={{
84-
display: 'flex',
85-
gap: '48px',
86-
}}
87-
>
65+
<div style={{ fontSize: '32px', color: '#c9d1d9', marginBottom: '48px' }}>@{user}</div>
66+
<div style={{ display: 'flex', gap: '48px' }}>
8867
<div
8968
style={{
9069
display: 'flex',
@@ -96,27 +75,11 @@ export async function GET(req: NextRequest) {
9675
padding: '32px 48px',
9776
}}
9877
>
99-
<div
100-
style={{
101-
fontSize: '56px',
102-
fontWeight: 'bold',
103-
color: '#58a6ff',
104-
}}
105-
>
78+
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#58a6ff' }}>
10679
{totalCommits}
10780
</div>
108-
109-
<div
110-
style={{
111-
fontSize: '18px',
112-
color: '#8b949e',
113-
marginTop: '8px',
114-
}}
115-
>
116-
Total Commits
117-
</div>
81+
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>Total Commits</div>
11882
</div>
119-
12083
<div
12184
style={{
12285
display: 'flex',
@@ -128,27 +91,13 @@ export async function GET(req: NextRequest) {
12891
padding: '32px 48px',
12992
}}
13093
>
131-
<div
132-
style={{
133-
fontSize: '56px',
134-
fontWeight: 'bold',
135-
color: '#f78166',
136-
}}
137-
>
94+
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#f78166' }}>
13895
{longestStreak}
13996
</div>
140-
141-
<div
142-
style={{
143-
fontSize: '18px',
144-
color: '#8b949e',
145-
marginTop: '8px',
146-
}}
147-
>
97+
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
14898
Longest Streak 🔥
14999
</div>
150100
</div>
151-
152101
<div
153102
style={{
154103
display: 'flex',
@@ -160,36 +109,15 @@ export async function GET(req: NextRequest) {
160109
padding: '32px 48px',
161110
}}
162111
>
163-
<div
164-
style={{
165-
fontSize: '56px',
166-
fontWeight: 'bold',
167-
color: '#3fb950',
168-
}}
169-
>
112+
<div style={{ fontSize: '56px', fontWeight: 'bold', color: '#3fb950' }}>
170113
{currentStreak}
171114
</div>
172-
173-
<div
174-
style={{
175-
fontSize: '18px',
176-
color: '#8b949e',
177-
marginTop: '8px',
178-
}}
179-
>
115+
<div style={{ fontSize: '18px', color: '#8b949e', marginTop: '8px' }}>
180116
Current Streak ⚡
181117
</div>
182118
</div>
183119
</div>
184-
185-
<div
186-
style={{
187-
position: 'absolute',
188-
bottom: '32px',
189-
fontSize: '16px',
190-
color: '#484f58',
191-
}}
192-
>
120+
<div style={{ position: 'absolute', bottom: '32px', fontSize: '16px', color: '#484f58' }}>
193121
commitpulse.vercel.app
194122
</div>
195123
</div>,

app/api/streak/route.ts

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,29 @@ import { generateSVG } from '../../../lib/svg/generator';
66
import { getSecondsUntilUTCMidnight } from '../../../utils/time';
77
import type { BadgeParams } from '../../../types';
88
import { themes } from '../../../lib/svg/themes';
9+
import { streakParamsSchema } from '../../../lib/validations';
910

1011
export async function GET(request: Request) {
1112
try {
1213
const { searchParams } = new URL(request.url);
13-
const user = searchParams.get('user');
1414

15-
if (!user) {
16-
return new NextResponse('Missing "user" parameter', { status: 400 });
15+
// Parse and validate all incoming params through Zod schema
16+
const parseResult = streakParamsSchema.safeParse(Object.fromEntries(searchParams.entries()));
17+
18+
if (!parseResult.success) {
19+
return NextResponse.json(
20+
{ error: 'Invalid parameters', details: parseResult.error.flatten() },
21+
{ status: 400 }
22+
);
1723
}
1824

19-
const yearParam = searchParams.get('year');
20-
const from = yearParam ? `${yearParam}-01-01T00:00:00Z` : undefined;
21-
const to = yearParam ? `${yearParam}-12-31T23:59:59Z` : undefined;
25+
const { user, theme, bg, text, accent, scale, speed, radius, font, year, refresh } =
26+
parseResult.data;
27+
28+
const from = year ? `${year}-01-01T00:00:00Z` : undefined;
29+
const to = year ? `${year}-12-31T23:59:59Z` : undefined;
2230

23-
const themeName = searchParams.get('theme') || 'dark';
31+
const themeName = theme;
2432
const isAutoTheme = themeName === 'auto';
2533
const isRandomTheme = themeName === 'random';
2634
const selectedTheme = (() => {
@@ -33,51 +41,38 @@ export async function GET(request: Request) {
3341
return themes[themeName] || themes.dark;
3442
})();
3543

36-
const rawSpeed = searchParams.get('speed') || '8s';
37-
const speed = /^\d+(\.\d+)?s$/.test(rawSpeed) ? rawSpeed : '8s';
38-
39-
const rawScale = searchParams.get('scale');
40-
const scale = rawScale === 'log' ? 'log' : 'linear';
41-
42-
const font = searchParams.get('font') || undefined;
43-
4444
// Auto-theme ignores custom hex overrides — the SVG uses CSS
4545
// custom properties with a prefers-color-scheme media query, so
4646
// fixed colors would conflict with the dual-palette switching.
4747
const params: BadgeParams = {
4848
user,
49-
bg: isAutoTheme ? selectedTheme.bg : searchParams.get('bg') || selectedTheme.bg,
50-
text: isAutoTheme ? selectedTheme.text : searchParams.get('text') || selectedTheme.text,
51-
accent: isAutoTheme
52-
? selectedTheme.accent
53-
: searchParams.get('accent') || selectedTheme.accent,
54-
radius: searchParams.get('radius') || '8',
49+
bg: isAutoTheme ? selectedTheme.bg : bg || selectedTheme.bg,
50+
text: isAutoTheme ? selectedTheme.text : text || selectedTheme.text,
51+
accent: isAutoTheme ? selectedTheme.accent : accent || selectedTheme.accent,
52+
radius,
5553
speed,
5654
scale,
5755
font,
5856
autoTheme: isAutoTheme,
5957
};
6058

61-
const refresh = searchParams.get('refresh') === 'true';
62-
6359
const calendar = await fetchGitHubContributions(user, { bypassCache: refresh, from, to });
6460
const stats = calculateStreak(calendar);
6561

6662
const svg = generateSVG(stats, params, calendar);
6763

68-
// 4. Calculate Cache Control (Reset at UTC Midnight)
64+
//4. Calculate Cache Control (Reset at UTC Midnight)
6965
const secondsToMidnight = getSecondsUntilUTCMidnight();
7066
const cacheControl =
7167
refresh || isRandomTheme
7268
? 'no-cache, no-store, must-revalidate'
7369
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
7470

75-
// 5. Return the Image Response
71+
//5. Return the Image Response
7672
return new NextResponse(svg, {
7773
headers: {
7874
'Content-Type': 'image/svg+xml',
7975
'Cache-Control': cacheControl,
80-
8176
'Content-Security-Policy':
8277
"default-src 'none'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src https://fonts.gstatic.com;",
8378
},

lib/validations.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { z } from 'zod';
2+
3+
export const streakParamsSchema = z.object({
4+
// Required — missing user surfaces as "Missing" to match existing tests
5+
user: z.string({ error: 'Missing user parameter' }).min(1, { message: 'Missing user parameter' }),
6+
7+
theme: z.string().default('dark'),
8+
bg: z.string().optional(),
9+
text: z.string().optional(),
10+
accent: z.string().optional(),
11+
12+
// Silently fall back to 'linear' for unknown values (matches old behavior)
13+
scale: z.enum(['linear', 'log']).catch('linear').default('linear'),
14+
15+
// Silently fall back to '8s' for invalid format (matches old behavior)
16+
speed: z
17+
.string()
18+
.regex(/^\d+(\.\d+)?s$/)
19+
.catch('8s')
20+
.default('8s'),
21+
22+
radius: z.string().default('8'),
23+
font: z.string().optional(),
24+
year: z.string().optional(),
25+
refresh: z
26+
.string()
27+
.optional()
28+
.transform((val) => val === 'true'),
29+
});
30+
31+
export const githubParamsSchema = z.object({
32+
username: z
33+
.string({ error: 'Missing "username" parameter' })
34+
.min(1, { message: 'Username is required' }),
35+
refresh: z
36+
.string()
37+
.optional()
38+
.transform((val) => val === 'true'),
39+
});
40+
41+
export const ogParamsSchema = z.object({
42+
user: z.string().optional().default('unknown'),
43+
});
44+
45+
export type StreakParams = z.infer<typeof streakParamsSchema>;
46+
export type GithubParams = z.infer<typeof githubParamsSchema>;
47+
export type OgParams = z.infer<typeof ogParamsSchema>;

package-lock.json

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"mongoose": "^9.6.2",
2424
"next": "^16.2.3",
2525
"react": "19.2.4",
26-
"react-dom": "19.2.4"
26+
"react-dom": "19.2.4",
27+
"zod": "^4.4.3"
2728
},
2829
"devDependencies": {
2930
"@testing-library/react": "^16.3.2",

0 commit comments

Comments
 (0)