Skip to content

Commit 47e4add

Browse files
Syed Faisal Haqueclaude
authored andcommitted
feat(api): add JSON output mode for /api/streak via format=json parameter
Add format=json query parameter support to /api/streak so developers can programmatically consume streak data, monthly stats, and calendar data without parsing SVG. Invalid format values silently fall back to SVG. Closes JhaSourav07#2070 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a4dcf09 commit 47e4add

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';
@@ -194,6 +195,42 @@ export async function GET(request: Request) {
194195
}
195196
}
196197

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