Basic Architecture of Sport Web
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
import { baseConfig } from '@sport/eslint-config/base';
|
||||
|
||||
export default baseConfig;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@sport/api-client",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "The single sanctioned way for any frontend to talk to the REST API.",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "tsc -p tsconfig.json --watch --preserveWatchOutput",
|
||||
"clean": "rm -rf dist .turbo *.tsbuildinfo",
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sport/types": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sport/config": "workspace:*",
|
||||
"@sport/eslint-config": "workspace:*",
|
||||
"@types/node": "^22.19.0",
|
||||
"eslint": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { HttpClient, type HttpClientOptions } from './http-client';
|
||||
import { createHealthResource, type HealthResource } from './resources/health';
|
||||
|
||||
/**
|
||||
* Resource modules are added here as the backend grows — one file per bounded
|
||||
* context under `src/resources/`, mirroring the NestJS module names exactly.
|
||||
* Keep them thin: URL + types, no business logic and no caching policy (that is
|
||||
* the calling app's decision).
|
||||
*/
|
||||
export interface ApiClient {
|
||||
readonly http: HttpClient;
|
||||
readonly health: HealthResource;
|
||||
}
|
||||
|
||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
const http = new HttpClient(options);
|
||||
|
||||
return {
|
||||
http,
|
||||
health: createHealthResource(http),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
API_ERROR_CODES,
|
||||
type ApiErrorBody,
|
||||
type ApiErrorCode,
|
||||
type ApiFieldErrors,
|
||||
} from '@sport/types';
|
||||
|
||||
/**
|
||||
* Every failure — HTTP error, network failure, malformed body — surfaces as
|
||||
* this one class. Callers never have to inspect a Response object.
|
||||
*/
|
||||
export class ApiClientError extends Error {
|
||||
readonly code: ApiErrorCode;
|
||||
readonly status: number;
|
||||
readonly fields?: ApiFieldErrors;
|
||||
readonly requestId?: string;
|
||||
|
||||
constructor(params: {
|
||||
code: ApiErrorCode;
|
||||
message: string;
|
||||
status: number;
|
||||
fields?: ApiFieldErrors;
|
||||
requestId?: string;
|
||||
cause?: unknown;
|
||||
}) {
|
||||
super(params.message, { cause: params.cause });
|
||||
this.name = 'ApiClientError';
|
||||
this.code = params.code;
|
||||
this.status = params.status;
|
||||
this.fields = params.fields;
|
||||
this.requestId = params.requestId;
|
||||
}
|
||||
|
||||
static fromBody(body: ApiErrorBody, status: number, requestId?: string): ApiClientError {
|
||||
return new ApiClientError({
|
||||
code: body.code,
|
||||
message: body.message,
|
||||
status,
|
||||
fields: body.fields,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
static network(cause: unknown): ApiClientError {
|
||||
return new ApiClientError({
|
||||
code: API_ERROR_CODES.SERVICE_UNAVAILABLE,
|
||||
message: 'Could not reach the server. Please check your connection and try again.',
|
||||
status: 0,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
get isAuthError(): boolean {
|
||||
return this.status === 401;
|
||||
}
|
||||
|
||||
get isValidationError(): boolean {
|
||||
return this.code === API_ERROR_CODES.VALIDATION_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiClientError(error: unknown): error is ApiClientError {
|
||||
return error instanceof ApiClientError;
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { API_ERROR_CODES, type ApiResponse } from '@sport/types';
|
||||
|
||||
import { ApiClientError } from './errors';
|
||||
|
||||
export interface HttpClientOptions {
|
||||
/** Origin only, e.g. `http://localhost:4000`. The version prefix is added here. */
|
||||
baseUrl: string;
|
||||
/** Defaults to `v1`. */
|
||||
apiVersion?: string;
|
||||
/** Resolved per request so a rotated token is picked up without re-creating the client. */
|
||||
getAccessToken?: () => string | null | undefined | Promise<string | null | undefined>;
|
||||
/** Invoked once on 401 so the app can refresh or redirect. Return true to retry. */
|
||||
onUnauthorized?: () => boolean | Promise<boolean>;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface RequestOptions {
|
||||
query?: Record<string, string | number | boolean | null | undefined | readonly string[]>;
|
||||
headers?: Record<string, string>;
|
||||
signal?: AbortSignal;
|
||||
/** Forwarded verbatim to Next.js's extended fetch. Ignored elsewhere. */
|
||||
next?: { revalidate?: number | false; tags?: string[] };
|
||||
cache?: RequestCache;
|
||||
/** Send cookies (used by the refresh-token flow). */
|
||||
credentials?: RequestCredentials;
|
||||
}
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class HttpClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiVersion: string;
|
||||
private readonly options: HttpClientOptions;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(options: HttpClientOptions) {
|
||||
this.options = options;
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.apiVersion = options.apiVersion ?? 'v1';
|
||||
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
}
|
||||
|
||||
get<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
return this.request<T>('GET', path, undefined, options);
|
||||
}
|
||||
|
||||
post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
|
||||
return this.request<T>('POST', path, body, options);
|
||||
}
|
||||
|
||||
patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
|
||||
return this.request<T>('PATCH', path, body, options);
|
||||
}
|
||||
|
||||
put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<T> {
|
||||
return this.request<T>('PUT', path, body, options);
|
||||
}
|
||||
|
||||
delete<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
return this.request<T>('DELETE', path, undefined, options);
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: Method,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: RequestOptions,
|
||||
isRetry = false,
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(path, options?.query);
|
||||
const headers = new Headers({
|
||||
Accept: 'application/json',
|
||||
...this.options.defaultHeaders,
|
||||
...options?.headers,
|
||||
});
|
||||
|
||||
if (body !== undefined) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const token = await this.options.getAccessToken?.();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||
const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await this.fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
credentials: options?.credentials ?? 'include',
|
||||
...(options?.cache ? { cache: options.cache } : {}),
|
||||
...(options?.next ? { next: options.next } : {}),
|
||||
} as RequestInit);
|
||||
} catch (cause) {
|
||||
throw ApiClientError.network(cause);
|
||||
}
|
||||
|
||||
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
|
||||
const shouldRetry = await this.options.onUnauthorized();
|
||||
if (shouldRetry) {
|
||||
return this.request<T>(method, path, body, options, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const requestId = response.headers.get('x-request-id') ?? undefined;
|
||||
let payload: ApiResponse<T>;
|
||||
try {
|
||||
payload = (await response.json()) as ApiResponse<T>;
|
||||
} catch (cause) {
|
||||
throw new ApiClientError({
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'The server returned an unreadable response.',
|
||||
status: response.status,
|
||||
requestId,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
if (!payload.success) {
|
||||
throw ApiClientError.fromBody(payload.error, response.status, payload.meta?.requestId);
|
||||
}
|
||||
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
private buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
|
||||
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 0) url.searchParams.set(key, value.join(','));
|
||||
} else {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @sport/api-client
|
||||
*
|
||||
* Storefront and admin reach the backend through this package and nothing else.
|
||||
* No `fetch('/api/...')` calls scattered through components, no duplicated URL
|
||||
* building, no per-app error handling. When the API version bumps or a payload
|
||||
* changes, exactly one package needs updating.
|
||||
*
|
||||
* It is deliberately runtime-agnostic (plain `fetch`, no React) so the same
|
||||
* client works in React Server Components, route handlers, client components
|
||||
* and, later, a React Native app.
|
||||
*/
|
||||
|
||||
export { ApiClientError, isApiClientError } from './errors';
|
||||
export { HttpClient } from './http-client';
|
||||
export type { HttpClientOptions, RequestOptions } from './http-client';
|
||||
export { createApiClient } from './create-client';
|
||||
export type { ApiClient } from './create-client';
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface HealthCheckResult {
|
||||
status: 'ok' | 'degraded';
|
||||
uptimeSeconds: number;
|
||||
version: string;
|
||||
environment: string;
|
||||
dependencies: {
|
||||
database: DependencyStatus;
|
||||
redis: DependencyStatus;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DependencyStatus {
|
||||
status: 'up' | 'down';
|
||||
latencyMs: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HealthResource {
|
||||
check(): Promise<HealthCheckResult>;
|
||||
}
|
||||
|
||||
export function createHealthResource(http: HttpClient): HealthResource {
|
||||
return {
|
||||
check: () => http.get<HealthCheckResult>('/health', { cache: 'no-store' }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@sport/config/typescript/library.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user