Skip to content

Commit 7bb2eef

Browse files
authored
feat(api): add JSON output mode for /api/streak via format=json (JhaSourav07#2097)
## Description Fixes JhaSourav07#2070 Adds `format=json` query parameter to `/api/streak` so developers can programmatically access streak stats, monthly stats, and calendar data without parsing SVG. **Example:** `GET /api/streak?user=octocat&format=json` returns JSON with `stats`, `monthlyStats`, and `calendar` fields. Invalid format values silently fall back to SVG output. ### Changes - **`lib/validations.ts`** — Added `format` field to `baseStreakParamsSchema` with `.catch('svg')` fallback - **`app/api/streak/route.ts`** — Added JSON output branch before SVG generation with proper Cache-Control and X-Cache-Status headers - **`app/api/streak/route.test.ts`** — Added 7 tests covering JSON output, headers, defaults, and fallback behavior ## Pillar - [x] 🛠️ Other (Bug fix, refactoring, docs) ## Visual Preview No visual changes — this adds a new JSON output mode alongside the existing SVG rendering. SVG output is unchanged. ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally (`localhost:3000/api/streak?user=YOUR_USERNAME`). - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format (e.g., `feat(themes): ...`, `fix(calculate): ...`). - [ ] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have started the repo. - [x] I have made sure that i have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts).
2 parents c63f7a2 + 6834a5f commit 7bb2eef

3 files changed

Lines changed: 91 additions & 0 deletions

File tree

app/api/streak/route.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1337,4 +1337,54 @@ describe('GET /api/streak', () => {
13371337
expect(body).toContain('strictly for organizations');
13381338
});
13391339
});
1340+
1341+
describe('JSON output mode (format=json)', () => {
1342+
it('returns JSON with correct Content-Type when format=json is set', async () => {
1343+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1344+
expect(response.status).toBe(200);
1345+
expect(response.headers.get('Content-Type')).toContain('application/json');
1346+
});
1347+
1348+
it('returns stats, monthlyStats, and calendar in JSON response', async () => {
1349+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1350+
const data = await response.json();
1351+
1352+
expect(data.user).toBe('octocat');
1353+
expect(data.stats).toBeDefined();
1354+
expect(data.stats.currentStreak).toBeDefined();
1355+
expect(data.stats.longestStreak).toBeDefined();
1356+
expect(data.stats.totalContributions).toBeDefined();
1357+
expect(data.monthlyStats).toBeDefined();
1358+
expect(data.monthlyStats.currentMonthTotal).toBeDefined();
1359+
expect(data.calendar).toBeDefined();
1360+
expect(data.calendar.totalContributions).toBe(10);
1361+
expect(data.calendar.weeks).toHaveLength(2);
1362+
});
1363+
1364+
it('includes Cache-Control header in JSON response', async () => {
1365+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1366+
expect(response.headers.get('Cache-Control')).toContain('s-maxage=');
1367+
});
1368+
1369+
it('includes X-Cache-Status header in JSON response', async () => {
1370+
const response = await GET(makeRequest({ user: 'octocat', format: 'json' }));
1371+
expect(response.headers.get('X-Cache-Status')).toBe('HIT');
1372+
});
1373+
1374+
it('returns SVG when format is not set (default)', async () => {
1375+
const response = await GET(makeRequest({ user: 'octocat' }));
1376+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1377+
});
1378+
1379+
it('falls back to SVG for invalid format values', async () => {
1380+
const response = await GET(makeRequest({ user: 'octocat', format: 'xml' }));
1381+
expect(response.headers.get('Content-Type')).toBe('image/svg+xml');
1382+
});
1383+
1384+
it('uses org name as user field when org parameter is provided', async () => {
1385+
const response = await GET(makeRequest({ user: 'octocat', org: 'github', format: 'json' }));
1386+
const data = await response.json();
1387+
expect(data.user).toBe('github');
1388+
});
1389+
});
13401390
});

app/api/streak/route.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export async function GET(request: Request) {
9393
tz: tzParam,
9494
disable_particles,
9595
glow,
96+
format,
9697
} = parseResult.data;
9798

9899
const themeName = theme || 'dark';
@@ -196,6 +197,42 @@ export async function GET(request: Request) {
196197
}
197198
}
198199

200+
// ─── JSON output mode ──────────────────────────────────────────────────
201+
if (format === 'json') {
202+
const stats = calculateStreak(calendar, timezone, undefined, grace);
203+
const monthlyStats = calculateMonthlyStats(
204+
calendar,
205+
timezone,
206+
getMonthlyReferenceDate(year, timezone)
207+
);
208+
209+
const secondsToMidnight = tzParam
210+
? getSecondsUntilMidnightInTimezone(timezone)
211+
: getSecondsUntilUTCMidnight();
212+
const cacheControl = refresh
213+
? 'no-cache, no-store, must-revalidate'
214+
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;
215+
216+
return NextResponse.json(
217+
{
218+
user: targetEntity,
219+
stats,
220+
monthlyStats,
221+
calendar: {
222+
totalContributions: calendar.totalContributions,
223+
weeks: calendar.weeks,
224+
},
225+
},
226+
{
227+
headers: {
228+
'Cache-Control': cacheControl,
229+
'X-Cache-Status': refresh ? `BYPASS, fetched=${new Date().toISOString()}` : 'HIT',
230+
},
231+
}
232+
);
233+
}
234+
235+
// ─── SVG output mode (default) ──────────────────────────────────────────
199236
let svg = '';
200237
if (view === 'monthly') {
201238
const stats = calculateMonthlyStats(

lib/validations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ const baseStreakParamsSchema = z.object({
254254
glow: z.string().optional().transform(toBooleanFlag).default(true),
255255
entrance: z.enum(['rise', 'fade', 'slide', 'none']).catch('rise').default('rise'),
256256

257+
// Output format: 'svg' (default) or 'json' for programmatic access.
258+
// Invalid values silently fall back to 'svg'.
259+
format: z.enum(['svg', 'json']).catch('svg').default('svg'),
260+
257261
// layout parameter: strictly validated — unsupported values return a 400 Bad Request.
258262
layout: z
259263
.string()

0 commit comments

Comments
 (0)