Skip to content

Commit d3cae6a

Browse files
perf(github): optimize API response caching by sanitizing profile and repo objects
1 parent e6b5723 commit d3cae6a

2 files changed

Lines changed: 90 additions & 4 deletions

File tree

lib/github.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,32 @@ describe('fetchUserProfile', () => {
522522
expect(result.avatar_url).toBe(mockProfile.avatar_url);
523523
});
524524

525+
it('sanitizes the profile by removing extra fields before returning', async () => {
526+
const mockProfile = {
527+
login: 'octocat',
528+
name: 'The Octocat',
529+
avatar_url: 'https://avatar.url',
530+
public_repos: 8,
531+
followers: 100,
532+
following: 5,
533+
created_at: '2011-01-25T18:44:36Z',
534+
bio: 'GitHub mascot',
535+
location: 'San Francisco',
536+
extra_field: 'should be removed',
537+
another_extra: 123,
538+
plan: { name: 'pro', space: 1000 },
539+
};
540+
541+
vi.mocked(fetch).mockResolvedValue(mockResponse(mockProfile));
542+
543+
const result = (await fetchUserProfile('octocat')) as unknown as Record<string, unknown>;
544+
545+
expect(result.login).toBe('octocat');
546+
expect(result.extra_field).toBeUndefined();
547+
expect(result.another_extra).toBeUndefined();
548+
expect(result.plan).toEqual({ name: 'pro' });
549+
});
550+
525551
it('encodes the username before using it in the REST profile path', async () => {
526552
vi.mocked(fetch).mockResolvedValue(
527553
mockResponse({
@@ -568,6 +594,29 @@ describe('fetchUserRepos', () => {
568594
expect(result[0].stargazers_count).toBe(1);
569595
});
570596

597+
it('sanitizes repo objects by removing extra fields before returning', async () => {
598+
vi.mocked(fetch).mockResolvedValue(
599+
mockResponse([
600+
{
601+
name: 'some-repo',
602+
stargazers_count: 10,
603+
language: 'TypeScript',
604+
id: 12345,
605+
private: false,
606+
owner: { login: 'octocat' },
607+
},
608+
])
609+
);
610+
611+
const result = (await fetchUserRepos('octocat')) as unknown as Record<string, unknown>[];
612+
613+
expect(result[0].stargazers_count).toBe(10);
614+
expect(result[0].language).toBe('TypeScript');
615+
expect(result[0].id).toBeUndefined();
616+
expect(result[0].private).toBeUndefined();
617+
expect(result[0].owner).toBeUndefined();
618+
});
619+
571620
it('returns a full three-repo payload with the expected star counts and languages', async () => {
572621
const mockedRepos = [
573622
{ stargazers_count: 7, language: 'TypeScript' },

lib/github.ts

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,41 @@ interface GitHubUserProfile {
268268
plan?: { name?: string } | null;
269269
}
270270

271+
/**
272+
* Sanitizes a GitHub user profile to only include required fields.
273+
* This reduces the memory footprint of cached data.
274+
*/
275+
function sanitizeUserProfile(profile: GitHubUserProfile): GitHubUserProfile {
276+
return {
277+
login: profile.login,
278+
name: profile.name,
279+
avatar_url: profile.avatar_url,
280+
public_repos: profile.public_repos,
281+
followers: profile.followers,
282+
following: profile.following,
283+
created_at: profile.created_at,
284+
bio: profile.bio,
285+
location: profile.location,
286+
type: profile.type,
287+
plan: profile.plan ? { name: profile.plan.name } : null,
288+
};
289+
}
290+
291+
/**
292+
* Sanitizes a GitHub repository object to only include required fields.
293+
* This reduces the memory footprint of cached data.
294+
*/
295+
function sanitizeRepo(repo: GitHubRepo): GitHubRepo {
296+
return {
297+
name: repo.name,
298+
stargazers_count: repo.stargazers_count,
299+
language: repo.language,
300+
fork: repo.fork,
301+
forks_count: repo.forks_count,
302+
updated_at: repo.updated_at,
303+
};
304+
}
305+
271306
export function cacheKey(
272307
kind: 'contributions' | 'profile' | 'repos' | 'repos:contributed',
273308
username: string,
@@ -580,8 +615,9 @@ async function fetchProfileUncached(
580615
}
581616

582617
const profile = (await res.json()) as GitHubUserProfile;
583-
if (!options.bypassCache) await profileCache.set(key, profile, GITHUB_CACHE_TTL_MS);
584-
return profile;
618+
const sanitizedProfile = sanitizeUserProfile(profile);
619+
if (!options.bypassCache) await profileCache.set(key, sanitizedProfile, GITHUB_CACHE_TTL_MS);
620+
return sanitizedProfile;
585621
}
586622

587623
export async function fetchUserRepos(
@@ -619,7 +655,7 @@ async function fetchReposUncached(
619655
}
620656

621657
const firstPageRepos = (await firstPageRes.json()) as GitHubRepo[];
622-
const allRepos: GitHubRepo[] = [...firstPageRepos];
658+
const allRepos: GitHubRepo[] = firstPageRepos.map(sanitizeRepo);
623659

624660
const MAX_PAGES = 3;
625661

@@ -646,7 +682,8 @@ async function fetchReposUncached(
646682
throw new Error(`GitHub REST API error: ${response.status}`);
647683
}
648684

649-
return (await response.json()) as GitHubRepo[];
685+
const repos = (await response.json()) as GitHubRepo[];
686+
return repos.map(sanitizeRepo);
650687
})
651688
);
652689

0 commit comments

Comments
 (0)