Stage M2
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { HttpClient, type HttpClientOptions } from './http-client';
|
||||
import { createAdminResource, type AdminResource } from './resources/admin';
|
||||
import { createAuthResource, type AuthResource } from './resources/auth';
|
||||
import { createCatalogResource, type CatalogResource } from './resources/catalog';
|
||||
import { createHealthResource, type HealthResource } from './resources/health';
|
||||
|
||||
@@ -12,6 +14,8 @@ export interface ApiClient {
|
||||
readonly http: HttpClient;
|
||||
readonly health: HealthResource;
|
||||
readonly catalog: CatalogResource;
|
||||
readonly auth: AuthResource;
|
||||
readonly admin: AdminResource;
|
||||
}
|
||||
|
||||
export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
@@ -21,5 +25,7 @@ export function createApiClient(options: HttpClientOptions): ApiClient {
|
||||
http,
|
||||
health: createHealthResource(http),
|
||||
catalog: createCatalogResource(http),
|
||||
auth: createAuthResource(http),
|
||||
admin: createAdminResource(http),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { HttpClient } from './http-client';
|
||||
|
||||
/**
|
||||
* Regression tests for two bugs that only manifested in a browser, and so
|
||||
* survived every server-side and curl-based check.
|
||||
*/
|
||||
describe('HttpClient', () => {
|
||||
const okResponse = () =>
|
||||
new Response(JSON.stringify({ success: true, data: { ok: true }, meta: {} }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
|
||||
it('calls the default fetch with the global receiver, not the client instance', async () => {
|
||||
// Browsers require `fetch` to be invoked with Window as `this`. Storing
|
||||
// `globalThis.fetch` on the instance and calling `this.fetchImpl(...)`
|
||||
// passes the HttpClient as the receiver and throws "Illegal invocation" —
|
||||
// in the browser only. Node does not care, which is precisely why every
|
||||
// server-side check and every curl passed while the browser was broken.
|
||||
const original = globalThis.fetch;
|
||||
const receivers: unknown[] = [];
|
||||
|
||||
globalThis.fetch = function (this: unknown) {
|
||||
// Pushed rather than assigned to a local: capturing the receiver is the
|
||||
// point of the test, and a plain alias trips `no-this-alias`.
|
||||
receivers.push(this);
|
||||
return Promise.resolve(okResponse());
|
||||
} as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
// No fetchImpl — exercise the default path, which is the one that broke.
|
||||
const client = new HttpClient({ baseUrl: 'http://api.test' });
|
||||
await client.get('/thing');
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
expect(receivers).toHaveLength(1);
|
||||
expect(receivers[0]).toBe(globalThis);
|
||||
expect(receivers[0]).not.toBeInstanceOf(HttpClient);
|
||||
});
|
||||
|
||||
it('does not retry a request that opted out, so refresh cannot recurse', async () => {
|
||||
// `onUnauthorized` refreshes by calling the refresh endpoint. If that call
|
||||
// is itself retryable, its own 401 triggers another refresh — an unbounded
|
||||
// loop that hammers the API from the browser.
|
||||
let calls = 0;
|
||||
let refreshes = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(new Response('{}', { status: 401 }));
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => {
|
||||
refreshes += 1;
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.post('/auth/refresh', undefined, { skipAuthRetry: true }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(calls).toBe(1);
|
||||
expect(refreshes).toBe(0);
|
||||
});
|
||||
|
||||
it('still offers one retry for ordinary requests', async () => {
|
||||
let calls = 0;
|
||||
|
||||
const fetchImpl = (() => {
|
||||
calls += 1;
|
||||
return Promise.resolve(calls === 1 ? new Response('{}', { status: 401 }) : okResponse());
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const client = new HttpClient({
|
||||
baseUrl: 'http://api.test',
|
||||
fetchImpl,
|
||||
onUnauthorized: () => true,
|
||||
});
|
||||
|
||||
await expect(client.get('/protected')).resolves.toEqual({ ok: true });
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,15 @@ 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. */
|
||||
/**
|
||||
* Origin, e.g. `http://localhost:4000` — or an empty string to call the
|
||||
* current page's own origin.
|
||||
*
|
||||
* Same-origin is what the browser client uses: the refresh cookie is
|
||||
* `SameSite=Lax` and therefore only sent first-party, so requests must go
|
||||
* through the app's own host (Nginx in production, a Next.js rewrite in
|
||||
* development) rather than directly to the API host.
|
||||
*/
|
||||
baseUrl: string;
|
||||
/** Defaults to `v1`. */
|
||||
apiVersion?: string;
|
||||
@@ -13,7 +21,13 @@ export interface HttpClientOptions {
|
||||
onUnauthorized?: () => boolean | Promise<boolean>;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
/** Injectable for tests and for runtimes with a patched fetch (Next.js). */
|
||||
/**
|
||||
* Injectable for tests and for runtimes with a patched fetch.
|
||||
*
|
||||
* Must be independently callable — pass a mock, or `window.fetch.bind(window)`.
|
||||
* A bare `window.fetch` reference throws "Illegal invocation" in the browser
|
||||
* because it loses its `Window` receiver.
|
||||
*/
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
@@ -26,6 +40,15 @@ export interface RequestOptions {
|
||||
cache?: RequestCache;
|
||||
/** Send cookies (used by the refresh-token flow). */
|
||||
credentials?: RequestCredentials;
|
||||
/**
|
||||
* Opts this request out of the automatic re-authentication retry.
|
||||
*
|
||||
* MANDATORY on the auth endpoints themselves. `onUnauthorized` refreshes by
|
||||
* calling `/auth/refresh`; if that call is itself eligible for the retry,
|
||||
* its own 401 triggers another refresh, which 401s, which triggers another —
|
||||
* an unbounded loop that hammers the API from the browser. Ask how I know.
|
||||
*/
|
||||
skipAuthRetry?: boolean;
|
||||
}
|
||||
|
||||
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
@@ -42,7 +65,17 @@ export class HttpClient {
|
||||
this.options = options;
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||
this.apiVersion = options.apiVersion ?? 'v1';
|
||||
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
||||
/**
|
||||
* Wrapped, never stored bare.
|
||||
*
|
||||
* `globalThis.fetch` must be invoked with `Window` as its receiver in the
|
||||
* browser. Assigning it to an instance property and calling
|
||||
* `this.fetchImpl(...)` invokes it with the HttpClient as `this`, which
|
||||
* throws "Illegal invocation" — in the browser only. Node does not care,
|
||||
* so server-side rendering and curl both worked while every click in the
|
||||
* browser silently failed as a network error.
|
||||
*/
|
||||
this.fetchImpl = options.fetchImpl ?? ((input, init) => globalThis.fetch(input, init));
|
||||
}
|
||||
|
||||
get<T>(path: string, options?: RequestOptions): Promise<T> {
|
||||
@@ -106,7 +139,12 @@ export class HttpClient {
|
||||
throw ApiClientError.network(cause);
|
||||
}
|
||||
|
||||
if (response.status === 401 && !isRetry && this.options.onUnauthorized) {
|
||||
if (
|
||||
response.status === 401 &&
|
||||
!isRetry &&
|
||||
!options?.skipAuthRetry &&
|
||||
this.options.onUnauthorized
|
||||
) {
|
||||
const shouldRetry = await this.options.onUnauthorized();
|
||||
if (shouldRetry) {
|
||||
return this.request<T>(method, path, body, options, true);
|
||||
@@ -122,6 +160,17 @@ export class HttpClient {
|
||||
try {
|
||||
payload = (await response.json()) as ApiResponse<T>;
|
||||
} catch (cause) {
|
||||
/**
|
||||
* A non-JSON body on a 5xx means the request never reached the API —
|
||||
* a reverse proxy or dev rewrite answered with an HTML error page while
|
||||
* the upstream was down or restarting. Reporting that as "unreadable
|
||||
* response" sends people hunting for a serialisation bug; it is an
|
||||
* availability problem, and the offline message says so.
|
||||
*/
|
||||
if (response.status >= 500) {
|
||||
throw ApiClientError.network(cause);
|
||||
}
|
||||
|
||||
throw new ApiClientError({
|
||||
code: API_ERROR_CODES.INTERNAL_ERROR,
|
||||
message: 'The server returned an unreadable response.',
|
||||
@@ -140,7 +189,14 @@ export class HttpClient {
|
||||
|
||||
private buildUrl(path: string, query?: RequestOptions['query']): string {
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = new URL(`${this.baseUrl}/api/${this.apiVersion}${normalized}`);
|
||||
const url = new URL(
|
||||
`${this.baseUrl}/api/${this.apiVersion}${normalized}`,
|
||||
// Only used when baseUrl is relative. On the server a relative baseUrl is
|
||||
// a configuration error, and `new URL` will say so loudly.
|
||||
this.baseUrl.startsWith('http')
|
||||
? undefined
|
||||
: (globalThis as { location?: { origin: string } }).location?.origin,
|
||||
);
|
||||
|
||||
for (const [key, value] of Object.entries(query ?? {})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
|
||||
@@ -16,4 +16,12 @@ export { HttpClient } from './http-client';
|
||||
export type { HttpClientOptions, RequestOptions } from './http-client';
|
||||
export { createApiClient } from './create-client';
|
||||
export type { CatalogResource, ProductListQuery } from './resources/catalog';
|
||||
export type { AuthResource, LoginCredentials } from './resources/auth';
|
||||
export type {
|
||||
AdminResource,
|
||||
CreateUserPayload,
|
||||
RolePayload,
|
||||
UpdateUserPayload,
|
||||
UserListParams,
|
||||
} from './resources/admin';
|
||||
export type { ApiClient } from './create-client';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { OffsetPaginated, PermissionGroup, RoleDetail, UserSummary } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface UserListParams {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
q?: string;
|
||||
type?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone?: string;
|
||||
type: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface UpdateUserPayload {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
phone?: string | null;
|
||||
status?: 'ACTIVE' | 'INVITED' | 'SUSPENDED';
|
||||
type?: 'STAFF' | 'ADMIN' | 'SUPER_ADMIN';
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface RolePayload {
|
||||
key?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
/** Back-office administration. Every call requires an admin-audience token. */
|
||||
export interface AdminResource {
|
||||
listUsers(params?: UserListParams): Promise<OffsetPaginated<UserSummary>>;
|
||||
getUser(id: string): Promise<UserSummary>;
|
||||
createUser(payload: CreateUserPayload): Promise<UserSummary>;
|
||||
updateUser(id: string, payload: UpdateUserPayload): Promise<UserSummary>;
|
||||
resetUserPassword(id: string, password: string): Promise<{ ok: true }>;
|
||||
|
||||
listRoles(): Promise<RoleDetail[]>;
|
||||
getRole(id: string): Promise<RoleDetail>;
|
||||
listPermissions(): Promise<PermissionGroup[]>;
|
||||
createRole(
|
||||
payload: Required<Pick<RolePayload, 'key' | 'name'>> & RolePayload,
|
||||
): Promise<RoleDetail>;
|
||||
updateRole(id: string, payload: RolePayload): Promise<RoleDetail>;
|
||||
deleteRole(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function createAdminResource(http: HttpClient): AdminResource {
|
||||
// Administration data is never cached: an operator editing permissions must
|
||||
// see the result of their own change immediately.
|
||||
const uncached = { cache: 'no-store' } as const;
|
||||
|
||||
return {
|
||||
listUsers: (params = {}) =>
|
||||
http.get<OffsetPaginated<UserSummary>>('/admin/users', { ...uncached, query: { ...params } }),
|
||||
getUser: (id) => http.get<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, uncached),
|
||||
createUser: (payload) => http.post<UserSummary>('/admin/users', payload, uncached),
|
||||
updateUser: (id, payload) =>
|
||||
http.patch<UserSummary>(`/admin/users/${encodeURIComponent(id)}`, payload, uncached),
|
||||
resetUserPassword: (id, password) =>
|
||||
http.post<{ ok: true }>(
|
||||
`/admin/users/${encodeURIComponent(id)}/password`,
|
||||
{ password },
|
||||
uncached,
|
||||
),
|
||||
|
||||
listRoles: () => http.get<RoleDetail[]>('/admin/roles', uncached),
|
||||
getRole: (id) => http.get<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||
listPermissions: () => http.get<PermissionGroup[]>('/admin/roles/permissions', uncached),
|
||||
createRole: (payload) => http.post<RoleDetail>('/admin/roles', payload, uncached),
|
||||
updateRole: (id, payload) =>
|
||||
http.patch<RoleDetail>(`/admin/roles/${encodeURIComponent(id)}`, payload, uncached),
|
||||
deleteRole: (id) => http.delete<void>(`/admin/roles/${encodeURIComponent(id)}`, uncached),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { CurrentUser, LoginResult, RefreshResult, SessionSummary } from '@sport/types';
|
||||
|
||||
import type { HttpClient } from '../http-client';
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Storefront and admin have separate endpoints because they issue separate
|
||||
* cookies for separate audiences. Exposing them as one method with a flag would
|
||||
* make it far too easy to point a customer credential at the admin surface.
|
||||
*/
|
||||
export interface AuthResource {
|
||||
login(credentials: LoginCredentials): Promise<LoginResult>;
|
||||
adminLogin(credentials: LoginCredentials): Promise<LoginResult>;
|
||||
refresh(): Promise<RefreshResult>;
|
||||
adminRefresh(): Promise<RefreshResult>;
|
||||
logout(): Promise<void>;
|
||||
adminLogout(): Promise<void>;
|
||||
me(): Promise<CurrentUser>;
|
||||
sessions(): Promise<SessionSummary[]>;
|
||||
}
|
||||
|
||||
export function createAuthResource(http: HttpClient): AuthResource {
|
||||
/**
|
||||
* `cache: 'no-store'` on every call. An authentication response must never be
|
||||
* served from a cache — not Next's data cache, not a CDN, not the browser's.
|
||||
*/
|
||||
const uncached = {
|
||||
cache: 'no-store',
|
||||
/**
|
||||
* Every auth endpoint opts out of the 401 retry. Refresh is the mechanism
|
||||
* the retry *uses*, so letting it retry itself recurses without bound;
|
||||
* login and logout have nothing to re-authenticate with either.
|
||||
*/
|
||||
skipAuthRetry: true,
|
||||
} as const;
|
||||
|
||||
return {
|
||||
login: (credentials) => http.post<LoginResult>('/auth/login', credentials, uncached),
|
||||
adminLogin: (credentials) => http.post<LoginResult>('/auth/admin/login', credentials, uncached),
|
||||
refresh: () => http.post<RefreshResult>('/auth/refresh', undefined, uncached),
|
||||
adminRefresh: () => http.post<RefreshResult>('/auth/admin/refresh', undefined, uncached),
|
||||
logout: () => http.post<void>('/auth/logout', undefined, uncached),
|
||||
adminLogout: () => http.post<void>('/auth/admin/logout', undefined, uncached),
|
||||
me: () => http.get<CurrentUser>('/auth/me', uncached),
|
||||
sessions: () => http.get<SessionSummary[]>('/auth/sessions', uncached),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user