Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions packages/apisports/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import 'dotenv/config';
import {
ApiSportsAPIError,
makeApiSportsRequest,
normalizeQuery,
} from './client';
import { API_SPORTS_ROUTES } from './endpoints/routes';
import type { ApiSportsResponse } from './endpoints/types';
import {
ApiSportsEndpointOutputSchemas,
ApiSportsResponseSchema,
} from './endpoints/types';

const TEST_API_KEY = process.env.API_SPORTS_API_KEY;

describe('normalizeQuery', () => {
it('joins array query params with hyphens', () => {
expect(normalizeQuery({ ids: [1208002, 1208003] })).toEqual({
ids: '1208002-1208003',
});
expect(normalizeQuery({ ids: [1, 2], live: true })).toEqual({
ids: '1-2',
live: true,
});
});
});

describe('API-Sports auth body errors', () => {
it('throws auth body errors for invalid key', async () => {
await expect(
makeApiSportsRequest<ApiSportsResponse>('football', '/countries', {
apiKey: 'invalid',
}),
).rejects.toThrow(/token|application key/i);
});
});

(TEST_API_KEY ? describe : describe.skip)('API-Sports API Type Tests', () => {
if (!TEST_API_KEY) {
console.warn('Skipping: API_SPORTS_API_KEY not set');
}

it('football countries returns correct type', async () => {
const response = await makeApiSportsRequest<ApiSportsResponse>(
'football',
'/countries',
{ apiKey: TEST_API_KEY },
);

ApiSportsEndpointOutputSchemas.getCountries.parse(response);
expect(response.response).toBeDefined();
});

it('football timezone returns correct type', async () => {
const response = await makeApiSportsRequest<ApiSportsResponse>(
'football',
'/timezone',
{ apiKey: TEST_API_KEY },
);

ApiSportsEndpointOutputSchemas.getTimezone.parse(response);
expect(response.response).toBeDefined();
});

it('nba status returns correct type', async () => {
const response = await makeApiSportsRequest<ApiSportsResponse>(
'nba',
'/status',
{ apiKey: TEST_API_KEY },
);

ApiSportsResponseSchema.parse(response);
expect(response.response).toBeDefined();
});

it('standings stages route hits football host', async () => {
const route = API_SPORTS_ROUTES.getStandingsStages;
expect(route.sport).toBe('football');

const response = await makeApiSportsRequest<ApiSportsResponse>(
route.sport,
route.path,
{ apiKey: TEST_API_KEY, query: { league: 39, season: 2023 } },
);

ApiSportsEndpointOutputSchemas.getStandingsStages.parse(response);
expect(Array.isArray(response.response) || response.response).toBeTruthy();
});

it('standings groups route hits football host', async () => {
const route = API_SPORTS_ROUTES.getStandingsGroups;
expect(route.sport).toBe('football');

const response = await makeApiSportsRequest<ApiSportsResponse>(
route.sport,
route.path,
{ apiKey: TEST_API_KEY, query: { league: 39, season: 2023 } },
);

ApiSportsEndpointOutputSchemas.getStandingsGroups.parse(response);
});

it('games events route hits nfl host', async () => {
const route = API_SPORTS_ROUTES.getGamesEvents;
expect(route.sport).toBe('nfl');

const response = await makeApiSportsRequest<ApiSportsResponse>(
route.sport,
route.path,
{ apiKey: TEST_API_KEY, query: { id: 1 } },
);

ApiSportsEndpointOutputSchemas.getGamesEvents.parse(response);
});

it('throws on API-Sports body errors', async () => {
const error = await makeApiSportsRequest<ApiSportsResponse>(
'nba',
'/games/events',
{ apiKey: TEST_API_KEY },
).catch((e: unknown) => e);

expect(error).toBeInstanceOf(ApiSportsAPIError);
expect((error as Error).message).toMatch(/endpoint/i);
});
});
165 changes: 165 additions & 0 deletions packages/apisports/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http';
import { ApiError, request } from 'corsair/http';

export class ApiSportsAPIError extends Error {
public readonly status?: number;
public readonly statusText?: string;
// API error bodies vary by sport endpoint; unknown forces callers to narrow before use.
public readonly body?: unknown;
public readonly retryAfter?: number;

constructor(
message: string,
public readonly code?: number,
options?: { cause?: Error; body?: unknown },
) {
super(message, options);
this.name = 'ApiSportsAPIError';

if (options?.cause instanceof ApiError) {
this.status = options.cause.status;
this.statusText = options.cause.statusText;
this.body = options.cause.body;
this.retryAfter = options.cause.retryAfter;
} else if (options?.body !== undefined) {
this.body = options.body;
}
}
}

export type ApiSport =
| 'football'
| 'basketball'
| 'nba'
| 'afl'
| 'baseball'
| 'formula1'
| 'mma'
| 'nfl';

export const API_SPORTS_BASE_URLS: Record<ApiSport, string> = {
football: 'https://v3.football.api-sports.io',
basketball: 'https://v1.basketball.api-sports.io',
nba: 'https://v2.nba.api-sports.io',
afl: 'https://v1.afl.api-sports.io',
baseball: 'https://v1.baseball.api-sports.io',
formula1: 'https://v1.formula-1.api-sports.io',
mma: 'https://v1.mma.api-sports.io',
nfl: 'https://v1.american-football.api-sports.io',
};

export type ApiSportsQueryValue =
| string
| number
| boolean
| Array<string | number>
| undefined;

/** API-Sports multi-id filters expect `ids=1-2-3`, not repeated keys. */
export function normalizeQuery(
query: Record<string, ApiSportsQueryValue>,
): Record<string, string | number | boolean> {
const out: Record<string, string | number | boolean> = {};
for (const [key, value] of Object.entries(query)) {
if (value === undefined) continue;
if (Array.isArray(value)) {
out[key] = value.join('-');
} else {
out[key] = value;
}
}
return out;
}

function formatApiSportsErrors(errors: unknown): string {
if (Array.isArray(errors)) {
return errors
.map((e) => (typeof e === 'string' ? e : JSON.stringify(e)))
.join(', ');
}
if (errors && typeof errors === 'object') {
return Object.entries(errors as Record<string, unknown>)
.map(([k, v]) => `${k}: ${String(v)}`)
.join(', ');
}
return 'API-Sports request failed';
}

function hasApiSportsErrors(errors: unknown): boolean {
if (errors == null) return false;
if (Array.isArray(errors)) return errors.length > 0;
if (typeof errors === 'object')
return Object.keys(errors as object).length > 0;
return true;
}

// Catch values are untyped at runtime; narrow to ApiError/Error before rethrowing.
async function handleRequestError(error: unknown): Promise<never> {
if (error instanceof ApiError) {
throw new ApiSportsAPIError(error.message, error.status, {
cause: error,
});
}
if (error instanceof Error) {
throw new ApiSportsAPIError(error.message, undefined, { cause: error });
}
throw new ApiSportsAPIError('Unknown error');
}

/**
* Performs a GET request to a sport-specific API-Sports REST API.
*
* Auth: API key via the `x-apisports-key` request header.
*/
export async function makeApiSportsRequest<T>(
sport: ApiSport,
path: string,
options: {
apiKey?: string;
query?: Record<string, ApiSportsQueryValue>;
} = {},
): Promise<T> {
const { apiKey, query = {} } = options;

const config: OpenAPIConfig = {
BASE: API_SPORTS_BASE_URLS[sport],
VERSION: '1.0.0',
WITH_CREDENTIALS: false,
CREDENTIALS: 'omit',
TOKEN: undefined,
HEADERS: {
Accept: 'application/json',
...(apiKey ? { 'x-apisports-key': apiKey } : {}),
},
};

const requestOptions: ApiRequestOptions = {
method: 'GET',
url: path,
query: normalizeQuery(query),
};

try {
const response = await request<T>(config, requestOptions);
// API-Sports returns HTTP 200 with a non-empty `errors` object/array on failures.
if (
response &&
typeof response === 'object' &&
'errors' in response &&
hasApiSportsErrors((response as { errors?: unknown }).errors)
) {
const body = response as { errors?: unknown };
throw new ApiSportsAPIError(
formatApiSportsErrors(body.errors),
undefined,
{
body,
},
);
}
return response;
} catch (error) {
if (error instanceof ApiSportsAPIError) throw error;
return handleRequestError(error);
}
}
91 changes: 91 additions & 0 deletions packages/apisports/endpoints/afl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { logEventFromContext } from 'corsair/core';
import type { ApiSportsEndpoints } from '../index';
import { API_SPORTS_ROUTES } from './routes';
import { executeApiSportsRequest } from './shared';
import type { ApiSportsEndpointOutputs } from './types';

/** Get AFL Seasons */
export const getAflSeasons: ApiSportsEndpoints['getAflSeasons'] = async (
ctx,
input,
) => {
const route = API_SPORTS_ROUTES.getAflSeasons;
const response = await executeApiSportsRequest<
ApiSportsEndpointOutputs['getAflSeasons']
>(ctx, route.sport, route.path, { apiKey: ctx.key, query: input });
await logEventFromContext(
ctx,
'apisports.afl.getAflSeasons',
input ?? {},
'completed',
);
return response;
};

/** Get AFL Games */
export const getAflGames: ApiSportsEndpoints['getAflGames'] = async (
ctx,
input,
) => {
const route = API_SPORTS_ROUTES.getAflGames;
const response = await executeApiSportsRequest<
ApiSportsEndpointOutputs['getAflGames']
>(ctx, route.sport, route.path, { apiKey: ctx.key, query: input });
await logEventFromContext(
ctx,
'apisports.afl.getAflGames',
input ?? {},
'completed',
);
return response;
};

/** Get AFL Games Quarters */
export const getAflGamesQuarters: ApiSportsEndpoints['getAflGamesQuarters'] =
async (ctx, input) => {
const route = API_SPORTS_ROUTES.getAflGamesQuarters;
const response = await executeApiSportsRequest<
ApiSportsEndpointOutputs['getAflGamesQuarters']
>(ctx, route.sport, route.path, { apiKey: ctx.key, query: input });
await logEventFromContext(
ctx,
'apisports.afl.getAflGamesQuarters',
input ?? {},
'completed',
);
return response;
};

/** Get AFL Game Player Statistics */
export const getAflGamePlayerStatistics: ApiSportsEndpoints['getAflGamePlayerStatistics'] =
async (ctx, input) => {
const route = API_SPORTS_ROUTES.getAflGamePlayerStatistics;
const response = await executeApiSportsRequest<
ApiSportsEndpointOutputs['getAflGamePlayerStatistics']
>(ctx, route.sport, route.path, { apiKey: ctx.key, query: input });
await logEventFromContext(
ctx,
'apisports.afl.getAflGamePlayerStatistics',
input ?? {},
'completed',
);
return response;
};

/** Get AFL Standings */
export const getAflStandings: ApiSportsEndpoints['getAflStandings'] = async (
ctx,
input,
) => {
const route = API_SPORTS_ROUTES.getAflStandings;
const response = await executeApiSportsRequest<
ApiSportsEndpointOutputs['getAflStandings']
>(ctx, route.sport, route.path, { apiKey: ctx.key, query: input });
await logEventFromContext(
ctx,
'apisports.afl.getAflStandings',
input ?? {},
'completed',
);
return response;
};
Loading
Loading