Skip to content

Commit da63008

Browse files
Agam00Dhirenderchoudharycoderabbitai[bot]
authored
feat(collegefootballdata): implement College Football Data plugin (#820)
Co-authored-by: Dhirender Choudhary <Dhirenderchoudhary0001@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent cad173c commit da63008

44 files changed

Lines changed: 6614 additions & 2 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* Covers the transport: the Bearer token, the single-host base URL, and the
3+
* 429 retry. Network access is mocked, so this runs in CI.
4+
*/
5+
import { makeCollegeFootballDataRequest } from './client';
6+
7+
type Captured = {
8+
url: string;
9+
method: string;
10+
headers: Record<string, string>;
11+
};
12+
13+
type MockResponse = {
14+
ok?: boolean;
15+
status?: number;
16+
body?: unknown;
17+
headers?: Record<string, string>;
18+
};
19+
20+
let captured: Captured | undefined;
21+
let attempts = 0;
22+
23+
/**
24+
* Installs a fetch stub that answers each call with the next response in the
25+
* list, repeating the last one once the list is exhausted.
26+
*/
27+
function mockFetchSequence(responses: MockResponse[]) {
28+
captured = undefined;
29+
attempts = 0;
30+
// `url` is `unknown` rather than `RequestInfo | URL` because the stub only
31+
// ever needs `String(url)`.
32+
global.fetch = (async (url: unknown, init?: RequestInit) => {
33+
const headers: Record<string, string> = {};
34+
const raw = init?.headers;
35+
if (raw instanceof Headers) {
36+
raw.forEach((value, key) => {
37+
headers[key.toLowerCase()] = value;
38+
});
39+
} else {
40+
for (const [key, value] of Object.entries(
41+
(raw ?? {}) as Record<string, string>,
42+
)) {
43+
headers[key.toLowerCase()] = value;
44+
}
45+
}
46+
captured = { url: String(url), method: init?.method ?? 'GET', headers };
47+
48+
const response =
49+
responses[Math.min(attempts, responses.length - 1)] ??
50+
({} as MockResponse);
51+
attempts++;
52+
53+
const status = response.status ?? 200;
54+
const payload = response.body ?? [];
55+
return {
56+
ok: response.ok ?? status < 400,
57+
status,
58+
statusText: 'OK',
59+
url: String(url),
60+
headers: new Headers({
61+
'Content-Type': 'application/json',
62+
...response.headers,
63+
}),
64+
json: async () => payload,
65+
text: async () => JSON.stringify(payload),
66+
// Partial `Response` stub; only what the shared request helper reads.
67+
};
68+
}) as unknown as typeof global.fetch;
69+
}
70+
71+
function mockFetch(response: MockResponse) {
72+
mockFetchSequence([response]);
73+
}
74+
75+
describe('makeCollegeFootballDataRequest', () => {
76+
it('sends the bearer token', async () => {
77+
mockFetch({ body: [] });
78+
79+
await makeCollegeFootballDataRequest('/teams', 'test-key');
80+
81+
expect(captured?.headers.authorization).toBe('Bearer test-key');
82+
});
83+
84+
it('targets the single api.collegefootballdata.com host', async () => {
85+
mockFetch({ body: [] });
86+
87+
await makeCollegeFootballDataRequest('/teams', 'test-key');
88+
89+
expect(captured?.url).toContain('https://api.collegefootballdata.com/');
90+
expect(captured?.url).toContain('/teams');
91+
});
92+
93+
it('is always a GET - this API has no writes', async () => {
94+
mockFetch({ body: [] });
95+
96+
await makeCollegeFootballDataRequest('/games', 'test-key', {
97+
query: { year: 2023 },
98+
});
99+
100+
expect(captured?.method).toBe('GET');
101+
});
102+
103+
it('serializes query parameters', async () => {
104+
mockFetch({ body: [] });
105+
106+
await makeCollegeFootballDataRequest('/games', 'test-key', {
107+
query: { year: 2023, team: 'Alabama' },
108+
});
109+
110+
expect(captured?.url).toContain('year=2023');
111+
expect(captured?.url).toContain('team=Alabama');
112+
});
113+
114+
/**
115+
* `Retry-After: 3` is deliberately different from `client.ts`'s
116+
* `initialRetryDelay: 1000` (1s): if the retry actually waited on the
117+
* fallback backoff instead of the header, this test would still pass at
118+
* 1s of fake-timer advancement, hiding the bug. Advancing by only the
119+
* header's 3s and asserting the second request has landed by then (but
120+
* not before) proves the header value, not the fallback, drove the wait.
121+
*/
122+
it('retries once the provider answers 429, waiting exactly the Retry-After duration', async () => {
123+
jest.useFakeTimers();
124+
try {
125+
mockFetchSequence([
126+
{ status: 429, body: {}, headers: { 'Retry-After': '3' } },
127+
{ status: 200, body: [{ id: 1 }] },
128+
]);
129+
130+
const pending = makeCollegeFootballDataRequest<unknown[]>(
131+
'/teams',
132+
'test-key',
133+
);
134+
135+
await jest.advanceTimersByTimeAsync(2999);
136+
expect(attempts).toBe(1);
137+
138+
await jest.advanceTimersByTimeAsync(1);
139+
expect(attempts).toBe(2);
140+
141+
expect(await pending).toEqual([{ id: 1 }]);
142+
} finally {
143+
jest.useRealTimers();
144+
}
145+
});
146+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type {
2+
ApiRequestOptions,
3+
OpenAPIConfig,
4+
RateLimitConfig,
5+
} from 'corsair/http';
6+
import { request } from 'corsair/http';
7+
8+
const COLLEGE_FOOTBALL_DATA_API_BASE = 'https://api.collegefootballdata.com';
9+
10+
/**
11+
* The provider documents no published rate-limit numbers for the free
12+
* tier (not stated on collegefootballdata.com/key or the OpenAPI
13+
* document), so the retry loop reacts to a 429 when it arrives.
14+
*/
15+
const COLLEGE_FOOTBALL_DATA_RATE_LIMIT_CONFIG: RateLimitConfig = {
16+
enabled: true,
17+
maxRetries: 3,
18+
initialRetryDelay: 1000,
19+
backoffMultiplier: 2,
20+
headerNames: {
21+
retryAfter: 'Retry-After',
22+
},
23+
};
24+
25+
export type CollegeFootballDataRequestOptions = {
26+
method?: 'GET';
27+
query?: Record<string, string | number | boolean | string[] | undefined>;
28+
};
29+
30+
/**
31+
* Issues a College Football Data request with Bearer auth and rate-limit
32+
* retries.
33+
*
34+
* Every operation in this catalog is a GET against the single
35+
* `api.collegefootballdata.com` host, confirmed from the provider's
36+
* official OpenAPI 3.0.0 document (`api-docs.json`, version 5.24.0) -
37+
* `securitySchemes.apiKey` declares `{ type: "http", scheme: "bearer" }`,
38+
* so the key travels as `Authorization: Bearer {key}`, not a custom
39+
* header. A separate GraphQL endpoint exists at
40+
* `graphqldocs.collegefootballdata.com` but nothing in this 56-op catalog
41+
* uses it.
42+
*/
43+
export async function makeCollegeFootballDataRequest<T>(
44+
path: string,
45+
apiKey: string,
46+
options: CollegeFootballDataRequestOptions = {},
47+
): Promise<T> {
48+
const { method = 'GET', query } = options;
49+
50+
const config: OpenAPIConfig = {
51+
BASE: COLLEGE_FOOTBALL_DATA_API_BASE,
52+
VERSION: '5.24.0',
53+
WITH_CREDENTIALS: false,
54+
CREDENTIALS: 'omit',
55+
TOKEN: undefined,
56+
HEADERS: {
57+
'Content-Type': 'application/json',
58+
Authorization: `Bearer ${apiKey}`,
59+
},
60+
};
61+
62+
const requestOptions: ApiRequestOptions = {
63+
method,
64+
url: path,
65+
mediaType: 'application/json; charset=utf-8',
66+
query,
67+
};
68+
69+
return await request<T>(config, requestOptions, {
70+
rateLimitConfig: COLLEGE_FOOTBALL_DATA_RATE_LIMIT_CONFIG,
71+
});
72+
}

0 commit comments

Comments
 (0)