/* ============================================================================ STRAPI 5 REST CLIENT ---------------------------------------------------------------------------- SERVER ONLY. This module imports ~/lib/env.server, which the React Router Vite plugin refuses to include in the browser bundle - so calling any of these from component code is a build error, not a leaked API token. Call them from `loader` / `action` only. Strapi 5 returns FLAT entities: `{ data: { id, documentId, title, ... }, meta }` rather than v4's `{ data: { id, attributes: { ... } } }`, and a record is addressed by its `documentId`, not by `id`. The normaliser below still unwraps the v4 shapes, because a plugin route or a hand-written controller can hand back the old envelope. ========================================================================= */ import { data } from "react-router"; import { getServerEnv } from "./env.server"; /* --------------------------------------------------------------------------- RESPONSE SHAPES ------------------------------------------------------------------------ */ /** Fields Strapi 5 puts on every entity. Extend it with the content type. */ export interface StrapiEntity { readonly id: number; readonly documentId: string; readonly createdAt: string; readonly updatedAt: string; readonly publishedAt: string | null; readonly locale?: string; } export interface StrapiPagination { readonly page: number; readonly pageSize: number; readonly pageCount: number; readonly total: number; } export interface StrapiMeta { readonly pagination?: StrapiPagination; readonly [key: string]: unknown; } /** A single-entity or single-type response. */ export interface StrapiSingleResponse { readonly data: T; readonly meta: StrapiMeta; } /** A collection response, with `pagination` lifted out of `meta` for ease. */ export interface StrapiCollectionResponse { readonly data: T[]; readonly meta: StrapiMeta; readonly pagination?: StrapiPagination; } /** An upload-plugin media entity, as returned inside a populated field. */ export interface StrapiMedia { readonly id: number; readonly documentId?: string; readonly name: string; readonly alternativeText: string | null; readonly caption: string | null; readonly width: number | null; readonly height: number | null; readonly ext: string | null; readonly mime: string; readonly size: number; readonly url: string; readonly previewUrl: string | null; readonly formats: Readonly> | null; } export interface StrapiMediaFormat { readonly name: string; readonly ext: string; readonly mime: string; readonly width: number; readonly height: number; readonly size: number; readonly url: string; } export type StrapiMediaFormatName = | "thumbnail" | "small" | "medium" | "large"; /* --------------------------------------------------------------------------- QUERY ------------------------------------------------------------------------ */ export type StrapiSort = string | readonly string[]; /** `"*"`, a field list, or Strapi's nested populate object. */ export type StrapiPopulate = | "*" | readonly string[] | { readonly [key: string]: unknown }; export interface StrapiPaginationQuery { readonly page?: number; readonly pageSize?: number; readonly start?: number; readonly limit?: number; readonly withCount?: boolean; } export interface StrapiQuery { /** Content locale. Pass the site locale so /ar/* reads Arabic entries. */ readonly locale?: string; readonly populate?: StrapiPopulate; /** Nested filter object, e.g. `{ slug: { $eq: "about" } }`. */ readonly filters?: Readonly>; readonly sort?: StrapiSort; readonly fields?: readonly string[]; readonly pagination?: StrapiPaginationQuery; /** Strapi 5 draft & publish. Defaults to the API default (published). */ readonly status?: "published" | "draft"; /** Anything else Strapi or a plugin accepts, serialised verbatim. */ readonly params?: Readonly>; } export interface StrapiRequestInit { /** Abort the request after this many ms. Default 10000. */ readonly timeoutMs?: number; /** Forwarded to `fetch`, e.g. to chain a route's own AbortSignal. */ readonly signal?: AbortSignal; readonly headers?: Readonly>; /** Standard `fetch` caching hint, honoured by the runtime's fetch. */ readonly cache?: RequestCache; } /* --------------------------------------------------------------------------- ERRORS ------------------------------------------------------------------------ */ /** Strapi's own error envelope, when the response body carries one. */ export interface StrapiErrorBody { readonly status?: number; readonly name?: string; readonly message?: string; readonly details?: unknown; } /** Any non-2xx response, or a transport/timeout failure. */ export class StrapiError extends Error { override readonly name = "StrapiError"; /** HTTP status, or 0 when the request never completed. */ readonly status: number; readonly statusText: string; readonly url: string; readonly body: StrapiErrorBody | undefined; constructor(init: { message: string; status: number; statusText?: string; url: string; body?: StrapiErrorBody; cause?: unknown; }) { super(init.message, init.cause === undefined ? undefined : { cause: init.cause }); this.status = init.status; this.statusText = init.statusText ?? ""; this.url = init.url; this.body = init.body; } } export function isStrapiError(value: unknown): value is StrapiError { return value instanceof StrapiError; } /** True for a 404 from Strapi - i.e. "the entity is not there". */ export function isStrapiNotFound(value: unknown): boolean { return isStrapiError(value) && value.status === 404; } /** * Throw a React Router 404 so the root ErrorBoundary renders the localised * "Page not found" page. `isRouteErrorResponse(error)` sees it as a 404. */ export function notFound(message = "Not Found"): never { throw data(message, { status: 404, statusText: "Not Found" }); } /** * Turn "the CMS returned nothing" into a 404 inside a loader: * const event = requireEntity(await getBySlug("events", params.slug)); */ export function requireEntity( entity: T | null | undefined, message = "Not Found", ): T { if (entity === null || entity === undefined) notFound(message); return entity; } /* --------------------------------------------------------------------------- QUERY SERIALISATION Strapi speaks `qs` bracket syntax - filters[slug][$eq]=about, populate[cover][fields][0]=url, sort[0]=date:desc - so the serialiser walks the object rather than pulling in the `qs` package. ------------------------------------------------------------------------ */ function appendDeep( search: URLSearchParams, prefix: string, value: unknown, ): void { if (value === undefined || value === null) return; if (value instanceof Date) { search.append(prefix, value.toISOString()); return; } if (Array.isArray(value)) { value.forEach((item, index) => { appendDeep(search, `${prefix}[${index}]`, item); }); return; } if (typeof value === "object") { for (const [key, nested] of Object.entries(value as Record)) { appendDeep(search, `${prefix}[${key}]`, nested); } return; } search.append(prefix, String(value)); } /** Serialise a `StrapiQuery` into Strapi's `qs` bracket syntax. */ export function stringifyStrapiQuery(query: StrapiQuery = {}): string { const search = new URLSearchParams(); const { params, ...rest } = query; for (const [key, value] of Object.entries(rest)) { appendDeep(search, key, value); } if (params) { for (const [key, value] of Object.entries(params)) { appendDeep(search, key, value); } } // Strapi accepts these unescaped, and a query is far easier to read in a // log as `filters[date][$gte]=2026-01-01` than as `%5Bdate%5D%5B%24gte%5D`. return search .toString() .replace(/%5B/g, "[") .replace(/%5D/g, "]") .replace(/%24/g, "$") .replace(/%3A/g, ":"); } /* --------------------------------------------------------------------------- NORMALISATION ------------------------------------------------------------------------ */ function isPlainObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } /** * Flatten a v4-style `{ id, attributes }` entity and unwrap a v4 relation * envelope `{ data, meta? }`. Strapi 5 payloads pass through untouched: they * match neither signature. Both checks are deliberately narrow so a content * field genuinely called `data` is not mistaken for an envelope. */ export function normalizeStrapiValue(value: unknown): T { if (Array.isArray(value)) { return value.map((item) => normalizeStrapiValue(item)) as T; } if (!isPlainObject(value)) return value as T; const keys = Object.keys(value); const isRelationEnvelope = "data" in value && keys.length <= 2 && keys.every((key) => key === "data" || key === "meta"); if (isRelationEnvelope) { return normalizeStrapiValue(value.data) as T; } const isV4Entity = typeof value.id === "number" && isPlainObject(value.attributes); if (isV4Entity) { return { id: value.id, ...(normalizeStrapiValue(value.attributes) as Record), } as T; } const result: Record = {}; for (const [key, nested] of Object.entries(value)) { result[key] = normalizeStrapiValue(nested); } return result as T; } /* --------------------------------------------------------------------------- MEDIA ------------------------------------------------------------------------ */ const ABSOLUTE_URL = /^(?:[a-z][a-z\d+\-.]*:|\/\/)/i; /** * Absolutise a Strapi media URL. Local uploads come back as "/uploads/x.jpg"; * an S3/Cloudinary provider already returns an absolute URL, which is passed * through untouched. */ export function mediaUrl( url: string | null | undefined, baseUrl?: string, ): string | undefined { if (!url) return undefined; if (ABSOLUTE_URL.test(url)) return url; const origin = baseUrl ?? getServerEnv().STRAPI_URL; return `${origin.replace(/\/+$/, "")}/${url.replace(/^\/+/, "")}`; } /** The absolute URL of one responsive format, falling back to the original. */ export function mediaFormatUrl( media: StrapiMedia | null | undefined, format: StrapiMediaFormatName, baseUrl?: string, ): string | undefined { if (!media) return undefined; return mediaUrl(media.formats?.[format]?.url ?? media.url, baseUrl); } /** `alternativeText` when the CMS has one, otherwise "" (decorative). */ export function mediaAlt(media: StrapiMedia | null | undefined): string { return media?.alternativeText ?? ""; } /* --------------------------------------------------------------------------- CLIENT ------------------------------------------------------------------------ */ export interface StrapiClientOptions { /** Overrides STRAPI_URL. No trailing slash required. */ readonly baseUrl?: string; /** Overrides STRAPI_TOKEN. */ readonly token?: string; /** Injectable for tests. */ readonly fetch?: typeof fetch; /** Default request timeout in ms. Default 10000. */ readonly timeoutMs?: number; } export interface StrapiClient { readonly baseUrl: string; /** GET /api/{resource} - a collection type. */ getMany( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise>; /** GET /api/{resource}/{documentId} - one entity of a collection type. */ getOne( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; /** As `getOne`, but a 404 from Strapi resolves to null. */ getOneOrNull( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; /** GET /api/{resource} - a single type (about-page, site-settings, ...). */ getSingle( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; /** The first match for a filter, or null. */ findFirst( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; /** The entry whose `slug` field equals `slug`, or null. */ getBySlug( resource: string, slug: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; /** Escape hatch: any path under the Strapi origin, normalised and typed. */ request( path: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise; } const DEFAULT_TIMEOUT_MS = 10_000; function buildUrl(baseUrl: string, resource: string, query?: StrapiQuery): string { const path = resource.replace(/^\/+/, ""); const prefixed = path.startsWith("api/") ? path : `api/${path}`; const qs = stringifyStrapiQuery(query); return `${baseUrl}/${prefixed}${qs ? `?${qs}` : ""}`; } async function readErrorBody(response: Response): Promise { try { const payload: unknown = await response.json(); if (isPlainObject(payload) && isPlainObject(payload.error)) { return payload.error as StrapiErrorBody; } return undefined; } catch { return undefined; } } export function createStrapiClient( options: StrapiClientOptions = {}, ): StrapiClient { // Only read the environment when the caller has not supplied both values, // so a test can build a client without STRAPI_URL / STRAPI_TOKEN being set. const fullyConfigured = options.baseUrl !== undefined && options.token !== undefined; const env = fullyConfigured ? undefined : getServerEnv(); const baseUrl = (options.baseUrl ?? env?.STRAPI_URL ?? "").replace(/\/+$/, ""); const token = options.token ?? env?.STRAPI_TOKEN; const doFetch = options.fetch ?? fetch; const defaultTimeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; async function rawRequest( resource: string, query?: StrapiQuery, init: StrapiRequestInit = {}, ): Promise { const url = buildUrl(baseUrl, resource, query); const headers: Record = { accept: "application/json", ...init.headers, }; if (token) headers.authorization = `Bearer ${token}`; const timeoutSignal = AbortSignal.timeout(init.timeoutMs ?? defaultTimeout); const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal; let response: Response; try { response = await doFetch(url, { method: "GET", headers, signal, ...(init.cache ? { cache: init.cache } : {}), }); } catch (cause) { const aborted = cause instanceof Error && cause.name === "TimeoutError"; throw new StrapiError({ message: aborted ? `Strapi request timed out after ${init.timeoutMs ?? defaultTimeout}ms: ${url}` : `Strapi request failed: ${url}`, status: 0, url, cause, }); } if (!response.ok) { const body = await readErrorBody(response); throw new StrapiError({ message: body?.message ?? `Strapi responded ${response.status} ${response.statusText} for ${url}`, status: response.status, statusText: response.statusText, url, body, }); } if (response.status === 204) return undefined as T; try { return (await response.json()) as T; } catch (cause) { throw new StrapiError({ message: `Strapi returned a non-JSON body for ${url}`, status: response.status, statusText: response.statusText, url, cause, }); } } async function envelope( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise<{ data: unknown; meta: StrapiMeta }> { const payload = await rawRequest<{ data?: unknown; meta?: StrapiMeta }>( resource, query, init, ); return { data: payload?.data, meta: (payload?.meta ?? {}) as StrapiMeta, }; } const client: StrapiClient = { baseUrl, async getMany( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise> { const { data: payload, meta } = await envelope(resource, query, init); const entries = Array.isArray(payload) ? payload : []; return { data: entries.map((entry) => normalizeStrapiValue(entry)), meta, pagination: meta.pagination, }; }, async getOne( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { const { data: payload } = await envelope( `${resource}/${encodeURIComponent(documentId)}`, query, init, ); return normalizeStrapiValue(payload); }, async getOneOrNull( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { try { return await client.getOne(resource, documentId, query, init); } catch (error) { if (isStrapiNotFound(error)) return null; throw error; } }, async getSingle( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { try { const { data: payload } = await envelope(resource, query, init); if (payload === null || payload === undefined) return null; return normalizeStrapiValue(payload); } catch (error) { if (isStrapiNotFound(error)) return null; throw error; } }, async findFirst( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { const result = await client.getMany( resource, { ...query, pagination: { ...query?.pagination, page: 1, pageSize: 1 }, }, init, ); return result.data[0] ?? null; }, async getBySlug( resource: string, slug: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return client.findFirst( resource, { ...query, filters: { ...query?.filters, slug: { $eq: slug } }, }, init, ); }, async request( path: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { const payload = await rawRequest(path, query, init); return normalizeStrapiValue(payload); }, }; return client; } let defaultClient: StrapiClient | undefined; /** The process-wide client, built from STRAPI_URL / STRAPI_TOKEN on first use. */ export function getStrapiClient(): StrapiClient { defaultClient ??= createStrapiClient(); return defaultClient; } /** Drop the memoised default client. Only useful in tests. */ export function resetStrapiClient(): void { defaultClient = undefined; } /* Convenience bindings so a loader reads as `getMany("events", { ... })`. */ export function getMany( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise> { return getStrapiClient().getMany(resource, query, init); } export function getOne( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return getStrapiClient().getOne(resource, documentId, query, init); } export function getOneOrNull( resource: string, documentId: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return getStrapiClient().getOneOrNull(resource, documentId, query, init); } export function getSingle( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return getStrapiClient().getSingle(resource, query, init); } export function findFirst( resource: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return getStrapiClient().findFirst(resource, query, init); } export function getBySlug( resource: string, slug: string, query?: StrapiQuery, init?: StrapiRequestInit, ): Promise { return getStrapiClient().getBySlug(resource, slug, query, init); }