701 lines
20 KiB
TypeScript
701 lines
20 KiB
TypeScript
/* ============================================================================
|
|
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);
|
|
}
|