113 lines
3.3 KiB
TypeScript
113 lines
3.3 KiB
TypeScript
/* ============================================================================
|
|
STRAPI WRITE - SERVER ONLY
|
|
----------------------------------------------------------------------------
|
|
~/lib/strapi.ts is a read client: the site renders from the CMS and never
|
|
mutates it. The two public forms in this section are the exception - an
|
|
event registration and an application to a call - so the POST lives here,
|
|
next to the routes that need it, and reuses that module's error type so a
|
|
caller handles one failure shape.
|
|
|
|
The `.server` suffix keeps it out of the browser bundle; it is imported by
|
|
`action` only.
|
|
========================================================================= */
|
|
|
|
import { getServerEnv } from "~/lib/env.server";
|
|
import { StrapiError } from "~/lib/strapi";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
|
|
export interface StrapiWriteInit {
|
|
readonly timeoutMs?: number;
|
|
readonly signal?: AbortSignal;
|
|
}
|
|
|
|
interface StrapiErrorEnvelope {
|
|
readonly error?: {
|
|
readonly status?: number;
|
|
readonly name?: string;
|
|
readonly message?: string;
|
|
readonly details?: unknown;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* `POST /api/{resource}` with Strapi's `{ data }` envelope, returning the
|
|
* created entity. Every failure - transport, timeout, 4xx, 5xx - arrives as a
|
|
* StrapiError, so the action can answer with one localised message instead of
|
|
* leaking the CMS's own wording to the reader.
|
|
*/
|
|
export async function createStrapiEntry<T>(
|
|
resource: string,
|
|
data: Readonly<Record<string, unknown>>,
|
|
init: StrapiWriteInit = {},
|
|
): Promise<T> {
|
|
const env = getServerEnv();
|
|
const url = `${env.STRAPI_URL}/api/${resource.replace(/^\/+/, "")}`;
|
|
|
|
const headers: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
accept: "application/json",
|
|
};
|
|
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
|
|
|
|
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
const signal = init.signal
|
|
? AbortSignal.any([init.signal, timeoutSignal])
|
|
: timeoutSignal;
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify({ data }),
|
|
signal,
|
|
});
|
|
} catch (cause) {
|
|
const timedOut = cause instanceof Error && cause.name === "TimeoutError";
|
|
throw new StrapiError({
|
|
message: timedOut
|
|
? `Strapi write timed out after ${timeoutMs}ms: ${url}`
|
|
: `Strapi write failed: ${url}`,
|
|
status: 0,
|
|
url,
|
|
cause,
|
|
});
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let body: StrapiErrorEnvelope["error"];
|
|
try {
|
|
body = ((await response.json()) as StrapiErrorEnvelope).error;
|
|
} catch {
|
|
body = undefined;
|
|
}
|
|
|
|
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 {
|
|
const payload = (await response.json()) as { data?: T };
|
|
return payload.data as T;
|
|
} catch (cause) {
|
|
throw new StrapiError({
|
|
message: `Strapi returned a non-JSON body for ${url}`,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url,
|
|
cause,
|
|
});
|
|
}
|
|
}
|