Files
sharper.ly/apps/web/app/lib/strapi.ts
T

701 lines
20 KiB
TypeScript
Raw Normal View History

2026-09-11 02:46:56 +02:00
/* ============================================================================
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<T> {
readonly data: T;
readonly meta: StrapiMeta;
}
/** A collection response, with `pagination` lifted out of `meta` for ease. */
export interface StrapiCollectionResponse<T> {
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<Record<string, StrapiMediaFormat>> | 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<Record<string, unknown>>;
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<Record<string, unknown>>;
}
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<Record<string, string>>;
/** 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<Event>("events", params.slug));
*/
export function requireEntity<T>(
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<string, unknown>)) {
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<string, unknown> {
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<T>(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<string, unknown>),
} as T;
}
const result: Record<string, unknown> = {};
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<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>>;
/** GET /api/{resource}/{documentId} - one entity of a collection type. */
getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
/** As `getOne`, but a 404 from Strapi resolves to null. */
getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** GET /api/{resource} - a single type (about-page, site-settings, ...). */
getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The first match for a filter, or null. */
findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** The entry whose `slug` field equals `slug`, or null. */
getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null>;
/** Escape hatch: any path under the Strapi origin, normalised and typed. */
request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T>;
}
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<StrapiErrorBody | undefined> {
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<T>(
resource: string,
query?: StrapiQuery,
init: StrapiRequestInit = {},
): Promise<T> {
const url = buildUrl(baseUrl, resource, query);
const headers: Record<string, string> = {
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<T>(
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<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
const { data: payload, meta } = await envelope(resource, query, init);
const entries = Array.isArray(payload) ? payload : [];
return {
data: entries.map((entry) => normalizeStrapiValue<T>(entry)),
meta,
pagination: meta.pagination,
};
},
async getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const { data: payload } = await envelope(
`${resource}/${encodeURIComponent(documentId)}`,
query,
init,
);
return normalizeStrapiValue<T>(payload);
},
async getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
return await client.getOne<T>(resource, documentId, query, init);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
try {
const { data: payload } = await envelope(resource, query, init);
if (payload === null || payload === undefined) return null;
return normalizeStrapiValue<T>(payload);
} catch (error) {
if (isStrapiNotFound(error)) return null;
throw error;
}
},
async findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
const result = await client.getMany<T>(
resource,
{
...query,
pagination: { ...query?.pagination, page: 1, pageSize: 1 },
},
init,
);
return result.data[0] ?? null;
},
async getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return client.findFirst<T>(
resource,
{
...query,
filters: { ...query?.filters, slug: { $eq: slug } },
},
init,
);
},
async request<T>(
path: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
const payload = await rawRequest<unknown>(path, query, init);
return normalizeStrapiValue<T>(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<Event>("events", { ... })`. */
export function getMany<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<StrapiCollectionResponse<T>> {
return getStrapiClient().getMany<T>(resource, query, init);
}
export function getOne<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T> {
return getStrapiClient().getOne<T>(resource, documentId, query, init);
}
export function getOneOrNull<T>(
resource: string,
documentId: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getOneOrNull<T>(resource, documentId, query, init);
}
export function getSingle<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getSingle<T>(resource, query, init);
}
export function findFirst<T>(
resource: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().findFirst<T>(resource, query, init);
}
export function getBySlug<T>(
resource: string,
slug: string,
query?: StrapiQuery,
init?: StrapiRequestInit,
): Promise<T | null> {
return getStrapiClient().getBySlug<T>(resource, slug, query, init);
}