Basic Architecture of Sport Web

This commit is contained in:
Nông Đức Huy
2026-08-11 13:37:25 +07:00
commit 8032fff6ac
262 changed files with 20348 additions and 0 deletions
+22
View File
@@ -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),
};
}
+64
View File
@@ -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;
}
+156
View File
@@ -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();
}
}
+18
View File
@@ -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' }),
};
}