Skip to content

Commit 10cbb0a

Browse files
authored
feat: add in-memory caching for GitHub API responses (JhaSourav07#47)
## Summary Adds short-term in-memory caching for GitHub API responses to reduce redundant API calls and improve performance. ## Changes - Added cache utility (`lib/cache.ts`) - Integrated caching into GitHub fetch logic - TTL set to 5–10 minutes - Added support for `refresh=true` to bypass cache ## Behavior - Same username within TTL returns cached data - Reduces repeated GitHub API calls - `refresh=true` forces fresh fetch ## Test - Ran `npm run format`, `npm run lint`, and `npm run test` - Verified cache hit/miss behavior locally Fixes JhaSourav07#46
2 parents 66c9804 + 18140b4 commit 10cbb0a

6 files changed

Lines changed: 215 additions & 18 deletions

File tree

app/api/github/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,22 @@ import { getFullDashboardData } from '@/lib/github';
44
export async function GET(request: Request) {
55
const { searchParams } = new URL(request.url);
66
const username = searchParams.get('username');
7+
const refresh = searchParams.get('refresh') === 'true';
78

89
if (!username) {
910
return NextResponse.json({ error: 'Username is required' }, { status: 400 });
1011
}
1112

1213
try {
13-
const data = await getFullDashboardData(username);
14+
const data = await getFullDashboardData(username, { bypassCache: refresh });
15+
const cacheControl = refresh
16+
? 'no-cache, no-store, must-revalidate'
17+
: 's-maxage=3600, stale-while-revalidate';
1418

1519
return NextResponse.json(data, {
1620
status: 200,
1721
headers: {
18-
'Cache-Control': 's-maxage=3600, stale-while-revalidate',
22+
'Cache-Control': cacheControl,
1923
},
2024
});
2125
} catch (error: unknown) {

app/api/streak/route.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ describe('GET /api/streak', () => {
9696
it('forwards the username to fetchGitHubContributions', async () => {
9797
await GET(makeRequest({ user: 'octocat' }));
9898

99-
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat');
99+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: false });
100100
});
101101

102102
it('embeds the username (uppercased) in the SVG title', async () => {
@@ -134,6 +134,12 @@ describe('GET /api/streak', () => {
134134
expect(response.headers.get('Cache-Control')).toBe('no-cache, no-store, must-revalidate');
135135
});
136136

137+
it('passes bypassCache=true when refresh=true', async () => {
138+
await GET(makeRequest({ user: 'octocat', refresh: 'true' }));
139+
140+
expect(fetchGitHubContributions).toHaveBeenCalledWith('octocat', { bypassCache: true });
141+
});
142+
137143
it('keeps normal caching when refresh is "false"', async () => {
138144
// Only the exact string "true" disables caching.
139145
const response = await GET(makeRequest({ user: 'octocat', refresh: 'false' }));

app/api/streak/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,15 @@ export async function GET(request: Request) {
3838
font,
3939
};
4040

41-
const calendar = await fetchGitHubContributions(user);
41+
const refresh = searchParams.get('refresh') === 'true';
42+
43+
const calendar = await fetchGitHubContributions(user, { bypassCache: refresh });
4244
const stats = calculateStreak(calendar);
4345

4446
const svg = generateSVG(stats, params, calendar);
4547

4648
// 4. Calculate Cache Control (Reset at UTC Midnight)
4749
const secondsToMidnight = getSecondsUntilUTCMidnight();
48-
const refresh = searchParams.get('refresh') === 'true';
4950
const cacheControl = refresh
5051
? 'no-cache, no-store, must-revalidate'
5152
: `public, s-maxage=${secondsToMidnight}, stale-while-revalidate=86400`;

lib/cache.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
type CacheItem<T> = {
2+
value: T;
3+
expiresAt: number;
4+
};
5+
6+
export class TTLCache<T> {
7+
private store = new Map<string, CacheItem<T>>();
8+
9+
get(key: string): T | null {
10+
const hit = this.store.get(key);
11+
if (!hit) return null;
12+
13+
if (Date.now() > hit.expiresAt) {
14+
this.store.delete(key);
15+
return null;
16+
}
17+
18+
return hit.value;
19+
}
20+
21+
set(key: string, value: T, ttlMs: number): void {
22+
this.store.set(key, { value, expiresAt: Date.now() + ttlMs });
23+
}
24+
25+
clear(): void {
26+
this.store.clear();
27+
}
28+
}

lib/github.test.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import {
55
fetchUserProfile,
66
fetchUserRepos,
77
getFullDashboardData,
8+
clearGitHubApiCacheForTests,
9+
GITHUB_CACHE_TTL_MS,
810
} from './github';
911
import type { ContributionCalendar } from '../types';
1012

@@ -28,6 +30,14 @@ function mockResponse(body: unknown, status = 200): Response {
2830
});
2931
}
3032

33+
beforeEach(() => {
34+
clearGitHubApiCacheForTests();
35+
});
36+
37+
afterEach(() => {
38+
clearGitHubApiCacheForTests();
39+
});
40+
3141
describe('fetchGitHubContributions', () => {
3242
beforeEach(() => {
3343
vi.spyOn(global, 'fetch');
@@ -160,9 +170,11 @@ describe('fetchUserRepos', () => {
160170
afterEach(() => vi.restoreAllMocks());
161171

162172
it('returns repos data on success', async () => {
163-
vi.mocked(fetch).mockResolvedValue(mockResponse([{ name: 'repo1' }]));
173+
vi.mocked(fetch).mockResolvedValue(
174+
mockResponse([{ stargazers_count: 1, language: 'TypeScript' }])
175+
);
164176
const result = await fetchUserRepos('octocat');
165-
expect(result[0].name).toBe('repo1');
177+
expect(result[0].stargazers_count).toBe(1);
166178
});
167179

168180
it('throws status code error on failure', async () => {
@@ -247,3 +259,67 @@ describe('getFullDashboardData', () => {
247259
await expect(getFullDashboardData('octocat')).rejects.toThrow('An unknown error occurred');
248260
});
249261
});
262+
263+
describe('GitHub API cache behavior', () => {
264+
beforeEach(() => {
265+
clearGitHubApiCacheForTests();
266+
vi.spyOn(global, 'fetch');
267+
vi.useRealTimers();
268+
});
269+
270+
afterEach(() => {
271+
vi.useRealTimers();
272+
vi.restoreAllMocks();
273+
clearGitHubApiCacheForTests();
274+
});
275+
276+
it('cache hit: second contributions call uses cached value', async () => {
277+
vi.mocked(fetch).mockResolvedValue(
278+
mockResponse({
279+
data: {
280+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
281+
},
282+
})
283+
);
284+
285+
await fetchGitHubContributions('octocat');
286+
await fetchGitHubContributions('octocat');
287+
288+
expect(fetch).toHaveBeenCalledTimes(1);
289+
});
290+
291+
it('refresh bypass: bypassCache=true forces a fresh fetch', async () => {
292+
vi.mocked(fetch).mockImplementation(async () =>
293+
mockResponse({
294+
data: {
295+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
296+
},
297+
})
298+
);
299+
300+
await fetchGitHubContributions('octocat');
301+
await fetchGitHubContributions('octocat', { bypassCache: true });
302+
303+
expect(fetch).toHaveBeenCalledTimes(2);
304+
});
305+
306+
it('cache expiry: expired entry triggers a new fetch', async () => {
307+
vi.useFakeTimers();
308+
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
309+
310+
vi.mocked(fetch).mockImplementation(async () =>
311+
mockResponse({
312+
data: {
313+
user: { contributionsCollection: { contributionCalendar: mockCalendar } },
314+
},
315+
})
316+
);
317+
318+
await fetchGitHubContributions('octocat');
319+
320+
vi.setSystemTime(Date.now() + GITHUB_CACHE_TTL_MS + 1);
321+
await fetchGitHubContributions('octocat');
322+
323+
expect(fetch).toHaveBeenCalledTimes(2);
324+
});
325+
});

lib/github.ts

Lines changed: 93 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { ContributionCalendar } from '../types';
44
import { calculateStreak } from './calculate';
5+
import { TTLCache } from './cache';
56

67
interface GitHubRepo {
78
stargazers_count: number;
@@ -22,12 +23,55 @@ type GitHubContributionResponse = {
2223
errors?: Array<{ message: string }>;
2324
};
2425

26+
type FetchOptions = {
27+
bypassCache?: boolean;
28+
};
29+
30+
export const GITHUB_CACHE_TTL_MS = 5 * 60 * 1000;
31+
32+
interface GitHubUserProfile {
33+
login: string;
34+
name: string | null;
35+
avatar_url: string;
36+
public_repos: number;
37+
followers: number;
38+
following: number;
39+
created_at: string;
40+
bio: string | null;
41+
location: string | null;
42+
plan?: { name?: string } | null;
43+
}
44+
45+
const contributionsCache = new TTLCache<ContributionCalendar>();
46+
const profileCache = new TTLCache<GitHubUserProfile>();
47+
const reposCache = new TTLCache<GitHubRepo[]>();
48+
49+
function cacheKey(kind: 'contributions' | 'profile' | 'repos', username: string): string {
50+
return `${kind}:${username.toLowerCase()}`;
51+
}
52+
53+
export function clearGitHubApiCacheForTests(): void {
54+
contributionsCache.clear();
55+
profileCache.clear();
56+
reposCache.clear();
57+
}
58+
2559
const getHeaders = () => ({
2660
Authorization: `bearer ${process.env.GITHUB_PAT || process.env.GITHUB_TOKEN}`,
2761
'Content-Type': 'application/json',
2862
});
2963

30-
export async function fetchGitHubContributions(username: string): Promise<ContributionCalendar> {
64+
export async function fetchGitHubContributions(
65+
username: string,
66+
options: FetchOptions = {}
67+
): Promise<ContributionCalendar> {
68+
const key = cacheKey('contributions', username);
69+
70+
if (!options.bypassCache) {
71+
const cached = contributionsCache.get(key);
72+
if (cached) return cached;
73+
}
74+
3175
const query = `
3276
query($login: String!) {
3377
user(login: $login) {
@@ -51,7 +95,7 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
5195
method: 'POST',
5296
headers: getHeaders(),
5397
body: JSON.stringify({ query, variables: { login: username } }),
54-
cache: 'no-store', // Cache handled at the API route
98+
cache: 'no-store', // Cache handled by our in-memory layer + API route headers
5599
});
56100

57101
if (!res.ok) {
@@ -69,10 +113,26 @@ export async function fetchGitHubContributions(username: string): Promise<Contri
69113
throw new Error(`GitHub user "${username}" not found`);
70114
}
71115

72-
return data.data.user.contributionsCollection.contributionCalendar;
116+
const calendar = data.data.user.contributionsCollection.contributionCalendar;
117+
118+
if (!options.bypassCache) {
119+
contributionsCache.set(key, calendar, GITHUB_CACHE_TTL_MS);
120+
}
121+
122+
return calendar;
73123
}
74124

75-
export async function fetchUserProfile(username: string) {
125+
export async function fetchUserProfile(
126+
username: string,
127+
options: FetchOptions = {}
128+
): Promise<GitHubUserProfile> {
129+
const key = cacheKey('profile', username);
130+
131+
if (!options.bypassCache) {
132+
const cached = profileCache.get(key);
133+
if (cached) return cached;
134+
}
135+
76136
const res = await fetch(`${GITHUB_REST_URL}/users/${username}`, {
77137
headers: getHeaders(),
78138
cache: 'no-store',
@@ -83,10 +143,26 @@ export async function fetchUserProfile(username: string) {
83143
throw new Error(`GitHub REST API error: ${res.status}`);
84144
}
85145

86-
return res.json();
146+
const profile = (await res.json()) as GitHubUserProfile;
147+
148+
if (!options.bypassCache) {
149+
profileCache.set(key, profile, GITHUB_CACHE_TTL_MS);
150+
}
151+
152+
return profile;
87153
}
88154

89-
export async function fetchUserRepos(username: string) {
155+
export async function fetchUserRepos(
156+
username: string,
157+
options: FetchOptions = {}
158+
): Promise<GitHubRepo[]> {
159+
const key = cacheKey('repos', username);
160+
161+
if (!options.bypassCache) {
162+
const cached = reposCache.get(key);
163+
if (cached) return cached;
164+
}
165+
90166
const res = await fetch(`${GITHUB_REST_URL}/users/${username}/repos?per_page=100&sort=pushed`, {
91167
headers: getHeaders(),
92168
cache: 'no-store',
@@ -96,15 +172,21 @@ export async function fetchUserRepos(username: string) {
96172
throw new Error(`GitHub REST API error: ${res.status}`);
97173
}
98174

99-
return res.json();
175+
const repos = (await res.json()) as GitHubRepo[];
176+
177+
if (!options.bypassCache) {
178+
reposCache.set(key, repos, GITHUB_CACHE_TTL_MS);
179+
}
180+
181+
return repos;
100182
}
101183

102-
export async function getFullDashboardData(username: string) {
184+
export async function getFullDashboardData(username: string, options: FetchOptions = {}) {
103185
try {
104186
const [profileData, reposData, calendarData] = await Promise.all([
105-
fetchUserProfile(username),
106-
fetchUserRepos(username),
107-
fetchGitHubContributions(username),
187+
fetchUserProfile(username, options),
188+
fetchUserRepos(username, options),
189+
fetchGitHubContributions(username, options),
108190
]);
109191

110192
// Pre-compute streak + stars early so developerScore can use them

0 commit comments

Comments
 (0)