@beignet/core
Version:
Core framework primitives for Beignet
1,237 lines (1,149 loc) • 36.5 kB
text/typescript
import {
BEIGNET_ERROR_OWNER_HEADER,
type ContractLike,
encodeQueryTransport,
getContractHeaderSchemas,
type HttpContractConfig,
methodSupportsRequestBody,
parsePathTemplate,
QueryTransportError,
type ResolveContract,
resolveContract,
type StandardErrorResponseBody,
type StandardSchema,
type StandardSchemaV1,
} from "../contracts/index.js";
import { isErrorResponseBody, SchemaValidationError } from "../errors/index.js";
import type {
CallArgs,
ClientConfig,
EndpointCallArgs,
EndpointResult,
InferEndpointErrorResponse,
InferEndpointErrorResponseByStatus,
InferEndpointErrorStatus,
InferSuccessResponse,
} from "./types.js";
/**
* Source category for a `ContractError`.
*/
export type ContractErrorSource = "http" | "client" | "network" | "contract";
/**
* Narrow a contract error union by source.
*/
export type ContractErrorWithSource<
TError,
TSource extends ContractErrorSource,
> = Extract<TError, { readonly source: TSource }>;
/**
* Narrow a contract error union by HTTP status.
*/
export type ContractErrorWithStatus<TError, TStatus extends number> = Extract<
TError,
{ readonly status: TStatus }
>;
/**
* Narrow a contract error union by Beignet error code.
*/
export type ContractErrorWithCode<TError, TCode extends string> = Extract<
TError,
{ readonly code: TCode }
>;
/**
* Error for a non-2xx HTTP response.
*/
export type HttpContractError<
TBody = unknown,
TStatus extends number = number,
> = ContractError<TBody, TStatus, "http"> & {
readonly source: "http";
readonly status: TStatus;
readonly response: Response;
};
/**
* Error created by the client before a network request is made.
*/
export type ClientContractError = ContractError<
undefined,
undefined,
"client"
> & {
readonly source: "client";
readonly status: undefined;
readonly response: undefined;
};
/**
* Error created when the network request itself fails.
*/
export type NetworkContractError = ContractError<
undefined,
undefined,
"network"
> & {
readonly source: "network";
readonly status: undefined;
readonly response: undefined;
};
/**
* Error created when a response violates the contract.
*/
export type ResponseContractError<
TBody = unknown,
TStatus extends number | undefined = number | undefined,
> = ContractError<TBody, TStatus, "contract"> & {
readonly source: "contract";
readonly status: TStatus;
};
/**
* Union of all Beignet client error variants.
*/
export type AnyContractError =
| HttpContractError
| ClientContractError
| NetworkContractError
| ResponseContractError;
type EndpointCatalogErrorDefinition<TContract extends HttpContractConfig> =
TContract["metadata"] extends { errors: infer TErrors }
? TErrors extends Record<
string,
{ code: string; status: number; message: string }
>
? TErrors[keyof TErrors]
: never
: never;
type InferErrorDefinitionDetails<TDef> = TDef extends {
details: StandardSchemaV1;
}
? StandardSchemaV1.InferOutput<TDef["details"]>
: unknown;
type StandardErrorBodyForDefinition<TDef extends { code: string }> =
StandardErrorResponseBody & {
code: TDef["code"];
details?: InferErrorDefinitionDetails<TDef>;
};
type EndpointCatalogContractError<TContract extends HttpContractConfig> =
EndpointCatalogErrorDefinition<TContract> extends infer TDef
? TDef extends { code: string; status: number }
? HttpContractError<
StandardErrorBodyForDefinition<TDef>,
TDef["status"]
> & {
readonly code: TDef["code"];
readonly details?: InferErrorDefinitionDetails<TDef>;
}
: never
: never;
/**
* Infer route-owned error catalog codes declared by a contract.
*/
export type InferEndpointErrorCode<TContract extends HttpContractConfig> =
EndpointCatalogErrorDefinition<TContract>["code"];
/**
* Infer the full typed error union for a contract endpoint.
*/
export type InferEndpointContractError<TContract extends HttpContractConfig> =
| EndpointCatalogContractError<TContract>
| {
[TStatus in InferEndpointErrorStatus<TContract>]: HttpContractError<
InferEndpointErrorResponseByStatus<TContract, TStatus>,
TStatus
>;
}[InferEndpointErrorStatus<TContract>]
| HttpContractError<InferEndpointErrorResponse<TContract>, number>
| ClientContractError
| NetworkContractError
| ResponseContractError<
InferEndpointErrorResponse<TContract>,
number | undefined
>;
/**
* Error thrown by Beignet contract clients.
*
* `source` distinguishes HTTP error responses, client-side request mistakes,
* network failures, and contract drift such as response validation failures.
*/
export class ContractError<
TBody = unknown,
TStatus extends number | undefined = number | undefined,
TSource extends ContractErrorSource = ContractErrorSource,
> extends Error {
/**
* Error source category.
*/
readonly source: TSource;
/**
* HTTP status when a response was available.
*/
readonly status: TStatus;
/**
* Stable error code.
*/
readonly code?: string;
/**
* Parsed response body when available.
*/
readonly body?: TBody;
/**
* Structured error details when available.
*/
readonly details?: unknown;
/**
* Native fetch response when available.
*/
readonly response?: Response;
override cause?: unknown;
constructor(args: {
source: TSource;
status?: TStatus;
code?: string;
message: string;
body?: TBody;
details?: unknown;
response?: Response;
cause?: unknown;
}) {
super(args.message);
this.name = "ContractError";
this.source = args.source;
this.status = args.status as TStatus;
this.code = args.code;
this.body = args.body;
this.details = args.details;
this.response = args.response;
this.cause = args.cause;
}
/**
* Check whether this error has a specific HTTP status code.
*/
hasStatus<S extends number>(
status: S,
): this is this & { readonly status: S } {
return (this.status as number | undefined) === status;
}
/**
* Check whether this error came from a specific source.
*/
hasSource<S extends ContractErrorSource>(
source: S,
): this is this & { readonly source: S } {
return (this.source as ContractErrorSource) === source;
}
/**
* Check whether this error has a specific error code.
*/
hasCode<C extends string>(code: C): this is this & { code: C } {
return this.code === code;
}
}
/**
* Type guard to check if an unknown error is a ContractError,
* optionally narrowing by HTTP status code.
*
* @example
* ```ts
* try { await endpoint.call(...) }
* catch (err) {
* if (isContractError(err, 404)) {
* // err.status is 404
* }
* if (isContractError(err)) {
* // err is ContractError
* }
* }
* ```
*/
export function isContractError(err: unknown): err is AnyContractError;
export function isContractError<S extends number>(
err: unknown,
status: S,
): err is HttpContractError<unknown, S>;
export function isContractError<
TError extends AnyContractError,
S extends number,
>(
err: TError,
criteria: { status: S },
): err is ContractErrorWithStatus<TError, S>;
export function isContractError<
TError extends AnyContractError,
S extends ContractErrorSource,
>(
err: TError,
criteria: { source: S },
): err is ContractErrorWithSource<TError, S>;
export function isContractError<
TError extends ContractError,
Status extends number,
Source extends ContractErrorSource,
>(
err: TError,
criteria: { status: Status; source: Source },
): err is ContractErrorWithSource<
ContractErrorWithStatus<TError, Status>,
Source
>;
export function isContractError<
TError extends ContractError,
Code extends string,
>(
err: TError,
criteria: { code: Code },
): err is ContractErrorWithCode<TError, Code>;
export function isContractError<
TError extends ContractError,
Status extends number,
Code extends string,
>(
err: TError,
criteria: { status: Status; code: Code },
): err is ContractErrorWithCode<ContractErrorWithStatus<TError, Status>, Code>;
export function isContractError<
TError extends ContractError,
Source extends ContractErrorSource,
Code extends string,
>(
err: TError,
criteria: { source: Source; code: Code },
): err is ContractErrorWithCode<ContractErrorWithSource<TError, Source>, Code>;
export function isContractError<
TError extends ContractError,
Status extends number,
Source extends ContractErrorSource,
Code extends string,
>(
err: TError,
criteria: { status: Status; source: Source; code: Code },
): err is ContractErrorWithCode<
ContractErrorWithSource<ContractErrorWithStatus<TError, Status>, Source>,
Code
>;
export function isContractError<S extends number>(
err: unknown,
criteria: { status: S },
): err is ContractError<unknown, S>;
export function isContractError<S extends ContractErrorSource>(
err: unknown,
criteria: { source: S },
): err is ContractError<unknown, number | undefined, S>;
export function isContractError<
Status extends number,
Source extends ContractErrorSource,
>(
err: unknown,
criteria: { status: Status; source: Source },
): err is ContractErrorWithSource<HttpContractError<unknown, Status>, Source>;
export function isContractError<Code extends string>(
err: unknown,
criteria: { code: Code },
): err is ContractError<unknown, number | undefined> & { readonly code: Code };
export function isContractError(
err: unknown,
criteria?:
| number
| { code?: string; source?: ContractErrorSource; status?: number },
): err is AnyContractError {
const status = typeof criteria === "number" ? criteria : criteria?.status;
const source = typeof criteria === "object" ? criteria.source : undefined;
const code = typeof criteria === "object" ? criteria.code : undefined;
return (
err instanceof ContractError &&
(status === undefined || err.status === status) &&
(source === undefined || err.source === source) &&
(code === undefined || err.code === code)
);
}
function formatQuotedList(values: string[]): string {
return values.map((value) => `"${value}"`).join(", ");
}
class MalformedResponseJsonError extends Error {
readonly parseError: unknown;
constructor(parseError: unknown) {
super("Response body contains malformed JSON.");
this.name = "MalformedResponseJsonError";
this.parseError = parseError;
}
}
/**
* Generate an idempotency key. Idempotency keys only need to be unique per
* request, not cryptographically strong, so this degrades gracefully when
* `crypto.randomUUID` is unavailable — notably in non-secure browsing
* contexts (plain `http://` on a host other than `localhost`, e.g. a LAN IP
* or a Tailscale hostname), where the Web Crypto API is not exposed and
* `crypto.randomUUID()` would throw, breaking every mutation.
*/
function createIdempotencyKey(): string {
const cryptoObj =
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
if (typeof cryptoObj?.randomUUID === "function") {
return cryptoObj.randomUUID();
}
const bytes = new Uint8Array(16);
if (typeof cryptoObj?.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = Math.floor(Math.random() * 256);
}
}
// RFC 4122 version 4 layout.
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
function createMissingPathParamsMessage(
path: string,
missing: string[],
provided: string[],
): string {
const label = missing.length === 1 ? "parameter" : "parameters";
const providedSuffix = provided.length
? ` (provided: ${provided.join(", ")})`
: "";
return `Missing required path ${label} ${formatQuotedList(missing)} for path "${path}"${providedSuffix}`;
}
/**
* Validate data using a Standard Schema validator
* Throws SchemaValidationError if validation fails
*/
async function validateSchema<T>(
schema: StandardSchemaV1<unknown, T>,
data: unknown,
): Promise<T> {
const result = await schema["~standard"].validate(data);
if (result.issues?.length) {
throw new SchemaValidationError(result.issues);
}
if ("value" in result) {
return result.value;
}
throw new Error("Invalid Standard Schema result: missing value");
}
function normalizeHeaderRecord(
headers: Record<string, string | undefined>,
): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [key, value] of Object.entries(headers)) {
if (value !== undefined) {
normalized[key.toLowerCase()] = value;
}
}
return normalized;
}
function serializeParsedHeaders(parsed: unknown): Record<string, string> {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (value !== undefined && value !== null) {
headers[key.toLowerCase()] = String(value);
}
}
return headers;
}
async function validateHeaderSchemas(
schemas: readonly StandardSchema[],
headers: Record<string, string>,
): Promise<Record<string, string>> {
let validatedHeaders = headers;
for (const schema of schemas) {
const parsed = await validateSchema(schema, headers);
validatedHeaders = {
...validatedHeaders,
...serializeParsedHeaders(parsed),
};
}
return validatedHeaders;
}
type PrimitiveParam = string | number | boolean;
type QueryParamValue =
| PrimitiveParam
| Date
| Record<string, unknown>
| null
| undefined
| Array<PrimitiveParam | Date | Record<string, unknown>>;
type PathParams = Record<string, PrimitiveParam>;
type QueryParams = Record<string, QueryParamValue>;
/**
* Typed client endpoint for one contract.
*/
export class Endpoint<
TContract extends HttpContractConfig,
TProvidedHeaders extends string = never,
> {
constructor(
private contract: TContract,
private config: ClientConfig<TProvidedHeaders>,
) {}
/**
* Check whether an unknown error is a `ContractError` for this endpoint.
*/
isError(err: unknown): err is InferEndpointContractError<TContract>;
isError<S extends InferEndpointErrorStatus<TContract>>(
err: unknown,
status: S,
): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
isError<S extends number>(
err: unknown,
status: S,
): err is HttpContractError<unknown, S>;
isError<S extends InferEndpointErrorStatus<TContract>>(
err: unknown,
criteria: { status: S },
): err is ContractErrorWithStatus<InferEndpointContractError<TContract>, S>;
isError<S extends ContractErrorSource>(
err: unknown,
criteria: { source: S },
): err is ContractErrorWithSource<InferEndpointContractError<TContract>, S>;
isError<C extends InferEndpointErrorCode<TContract>>(
err: unknown,
criteria: { code: C },
): err is ContractErrorWithCode<InferEndpointContractError<TContract>, C>;
isError<C extends string>(
err: unknown,
criteria: { code: C },
): err is InferEndpointContractError<TContract> & { readonly code: C };
isError<
Status extends InferEndpointErrorStatus<TContract>,
Source extends ContractErrorSource,
>(
err: unknown,
criteria: { status: Status; source: Source },
): err is ContractErrorWithSource<
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
Source
>;
isError<
Status extends InferEndpointErrorStatus<TContract>,
C extends InferEndpointErrorCode<TContract>,
>(
err: unknown,
criteria: { status: Status; code: C },
): err is ContractErrorWithCode<
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
C
>;
isError<
Source extends ContractErrorSource,
C extends InferEndpointErrorCode<TContract>,
>(
err: unknown,
criteria: { source: Source; code: C },
): err is ContractErrorWithCode<
ContractErrorWithSource<InferEndpointContractError<TContract>, Source>,
C
>;
isError<
Status extends InferEndpointErrorStatus<TContract>,
Source extends ContractErrorSource,
C extends InferEndpointErrorCode<TContract>,
>(
err: unknown,
criteria: { status: Status; source: Source; code: C },
): err is ContractErrorWithCode<
ContractErrorWithSource<
ContractErrorWithStatus<InferEndpointContractError<TContract>, Status>,
Source
>,
C
>;
isError(
err: unknown,
criteria?:
| number
| { code?: string; source?: ContractErrorSource; status?: number },
): err is InferEndpointContractError<TContract> {
return isContractError(err, criteria as never);
}
/**
* Call the endpoint and return the parsed success body.
*
* Throws `ContractError` when the request fails or the response violates the
* contract.
*/
async call(
...callArgs: CallArgs<TContract, TProvidedHeaders>
): Promise<InferSuccessResponse<TContract>> {
const result = await this.safeCall(...callArgs);
if (!result.ok) {
throw result.error;
}
return result.data;
}
/**
* Call the endpoint and return a typed result instead of throwing
* `ContractError`.
*/
async safeCall(
...callArgs: CallArgs<TContract, TProvidedHeaders>
): Promise<EndpointResult<TContract, InferEndpointContractError<TContract>>> {
const args = (callArgs[0] ?? {}) as EndpointCallArgs<
TContract,
TProvidedHeaders
>;
let phase: "client" | "network" | "contract" = "client";
let response: Response | undefined;
let responseStatus: number | undefined;
try {
if (args.body !== undefined && args.rawBody !== undefined) {
throw this.createError({
code: "INVALID_REQUEST_BODY",
message: "Pass either body or rawBody, not both.",
});
}
const methodSupportsBody = methodSupportsRequestBody(
this.contract.method,
);
if (
(args.body !== undefined || args.rawBody !== undefined) &&
!methodSupportsBody
) {
throw this.createError({
code: "INVALID_REQUEST_BODY",
message: `Request bodies are not supported for ${this.contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
});
}
let requestBody: BodyInit | undefined;
let requestBodyType: "json" | "raw" | undefined;
if (args.rawBody !== undefined && methodSupportsBody) {
requestBody = args.rawBody;
requestBodyType = "raw";
}
if (args.body !== undefined && methodSupportsBody) {
let bodyToSend = args.body;
if (this.config.validateInput && this.contract.body) {
try {
bodyToSend = (await validateSchema(
this.contract.body,
args.body,
)) as typeof bodyToSend;
} catch (err) {
if (err instanceof SchemaValidationError) {
throw this.createError({
code: "INPUT_VALIDATION_ERROR",
message: "Body validation failed",
details: err.issues,
});
}
throw err;
}
}
requestBody = JSON.stringify(bodyToSend);
requestBodyType = "json";
}
const url = await this.buildUrl(
args.path as PathParams | undefined,
args.query as QueryParams | undefined,
);
let headers = await this.buildHeaders(
args.headers as Record<string, string> | undefined,
requestBodyType === "json",
);
const idempotencyMeta = this.contract.metadata?.idempotency;
if (idempotencyMeta) {
const idempotencyHeader = (
idempotencyMeta.header ?? "idempotency-key"
).toLowerCase();
if (!headers[idempotencyHeader]) {
headers[idempotencyHeader] =
args.idempotencyKey ?? createIdempotencyKey();
}
}
if (this.config.validateInput) {
const headerSchemas = getContractHeaderSchemas(this.contract.headers);
if (headerSchemas.length > 0) {
try {
headers = await validateHeaderSchemas(headerSchemas, headers);
} catch (err) {
if (err instanceof SchemaValidationError) {
throw this.createError({
code: "INPUT_VALIDATION_ERROR",
message: "Headers validation failed",
details: err.issues,
});
}
throw err;
}
}
}
const fetchFn = this.config.fetch || fetch;
const options: RequestInit = {
method: this.contract.method,
headers,
signal: args.signal,
};
if (requestBody !== undefined) {
options.body = requestBody;
}
phase = "network";
response = await fetchFn(url, options);
phase = "contract";
responseStatus = response.status;
const shouldValidateResponses = this.config.validateResponses !== false;
// Handle non-2xx responses
if (!response.ok) {
let errorBody: unknown;
try {
errorBody = await parseResponseBody(response);
} catch (parseErr) {
if (!(parseErr instanceof MalformedResponseJsonError)) {
throw parseErr;
}
// JSON parse failed — still report the HTTP error
throw this.createError({
status: response.status,
code: "INVALID_JSON",
message: createInvalidJsonMessage("error", response.status),
cause: parseErr.parseError,
response,
source: "contract",
});
}
const validatedError = shouldValidateResponses
? await validateErrorBodyOrStandardEnvelope(
this.contract,
response,
errorBody,
)
: errorBody;
const errorPayload = getErrorPayload(validatedError);
throw this.createError({
status: response.status,
code: errorPayload.code || "HTTP_ERROR",
message: errorPayload.message || response.statusText,
details: errorPayload.details,
body: validatedError,
response,
source: "http",
});
}
// Parse response
let data: unknown;
try {
data = await parseResponseBody(response);
} catch (parseErr) {
if (!(parseErr instanceof MalformedResponseJsonError)) {
throw parseErr;
}
throw this.createError({
status: response.status,
code: "INVALID_JSON",
message: createInvalidJsonMessage("success", response.status),
cause: parseErr.parseError,
response,
source: "contract",
});
}
// Validate response if schema exists for this status
if (shouldValidateResponses) {
const statusKey = String(response.status);
const hasSchema = statusKey in this.contract.responses;
const hasDeclaredResponses =
Object.keys(this.contract.responses).length > 0;
const responseSchema = this.contract.responses[response.status];
if (hasSchema && responseSchema === null) {
if (data !== undefined && data !== null) {
throw this.createError({
status: response.status,
code: "RESPONSE_VALIDATION_ERROR",
message: `Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
details: [
{
message:
"Response body must be empty for a null response schema.",
},
],
body: data,
response,
source: "contract",
});
}
} else if (hasSchema && responseSchema) {
try {
data = await validateSchema(responseSchema, data);
} catch (err) {
if (err instanceof SchemaValidationError) {
throw this.createError({
status: response.status,
code: "RESPONSE_VALIDATION_ERROR",
message: `Response validation failed for ${this.contract.method} ${this.contract.path} (status ${response.status}, contract: ${this.contract.name})`,
details: err.issues,
cause: err,
body: data,
response,
source: "contract",
});
}
throw err;
}
} else if (!hasSchema && hasDeclaredResponses) {
throw this.createError({
status: response.status,
code: "UNDECLARED_RESPONSE_STATUS",
message: `Server returned undeclared status ${response.status} for ${this.contract.method} ${this.contract.path} (contract: ${this.contract.name})`,
body: data,
response,
source: "contract",
});
}
}
return {
ok: true,
status: response.status,
data: data as InferSuccessResponse<TContract>,
response,
};
} catch (err: unknown) {
if (err instanceof ContractError) {
const error = err as InferEndpointContractError<TContract>;
return {
ok: false,
status: error.status,
error,
response: error.response,
};
}
const source = phase;
const error = this.createError({
code:
source === "network"
? "NETWORK_ERROR"
: source === "contract"
? "RESPONSE_PROCESSING_ERROR"
: "CLIENT_ERROR",
message:
err instanceof Error
? err.message
: source === "network"
? "Network request failed"
: source === "contract"
? "Response processing failed"
: "Client request preparation failed",
cause: err,
source,
...(source === "contract" ? { status: responseStatus, response } : {}),
}) as InferEndpointContractError<TContract>;
return {
ok: false,
status: error.status,
error,
response: error.response,
};
}
}
/**
* Build the full URL with path and query parameters
*/
private async buildUrl(
path?: PathParams,
query?: QueryParams,
): Promise<string> {
let parsedPath: ReturnType<typeof parsePathTemplate>;
try {
parsedPath = parsePathTemplate(this.contract.path);
} catch (cause) {
throw new ContractError({
source: "client",
code: "INVALID_PATH_TEMPLATE",
message: cause instanceof Error ? cause.message : String(cause),
cause,
});
}
let pathToSerialize = path;
// Replace path parameters
if (path && parsedPath.keys.length > 0) {
// Validate path params if schema exists and validation is enabled
if (this.config.validateInput && this.contract.pathParams) {
try {
pathToSerialize = (await validateSchema(
this.contract.pathParams,
path,
)) as PathParams;
} catch (err) {
if (err instanceof SchemaValidationError) {
throw this.createError({
code: "INPUT_VALIDATION_ERROR",
message: "Path params validation failed",
details: err.issues,
});
}
throw err;
}
}
}
const normalizedPath = pathToSerialize ?? path;
const missingPathParams = [
...new Set(
parsedPath.keys.filter((key) => normalizedPath?.[key] === undefined),
),
];
if (missingPathParams.length) {
const provided = path ? Object.keys(path) : [];
throw new ContractError({
source: "client",
code: "MISSING_PATH_PARAMS",
message: createMissingPathParamsMessage(
this.contract.path,
missingPathParams,
provided,
),
});
}
let url = `/${parsedPath.segments
.map((segment) =>
segment.kind === "static"
? segment.value
: encodeURIComponent(String(normalizedPath?.[segment.name])),
)
.join("/")}`;
// Add query parameters
if (query) {
if (!this.contract.queryTransport) {
throw new ContractError({
source: "contract",
code: "INVALID_QUERY_TRANSPORT",
message: `Contract "${this.contract.name}" does not declare a query transport.`,
});
}
let params: URLSearchParams | undefined;
let transportFailure: { error: unknown } | undefined;
try {
// Serialize before validation so even a non-conforming Standard Schema
// that mutates its input cannot turn validation into a wire transform.
params = encodeQueryTransport(this.contract.queryTransport, query);
} catch (error) {
transportFailure = { error };
}
// Validate query params if schema exists and validation is enabled
if (this.config.validateInput && this.contract.query) {
try {
await validateSchema(this.contract.query, query);
} catch (err) {
if (err instanceof SchemaValidationError) {
throw this.createError({
code: "INPUT_VALIDATION_ERROR",
message: "Query params validation failed",
details: err.issues,
});
}
throw err;
}
}
if (transportFailure) {
if (transportFailure.error instanceof QueryTransportError) {
throw this.createError({
code: "INVALID_QUERY_PARAM",
message: "Query transport encoding failed",
details: transportFailure.error.issues,
});
}
throw transportFailure.error;
}
const queryString = params?.toString() ?? "";
if (queryString) {
url += `?${queryString}`;
}
}
// Prepend base URL, normalizing trailing/leading slashes
const baseUrl = this.config.baseUrl || "";
if (baseUrl && url.startsWith("/") && baseUrl.endsWith("/")) {
return baseUrl + url.slice(1);
}
return baseUrl + url;
}
/**
* Build request headers
*/
private async buildHeaders(
customHeaders?: Record<string, string>,
hasJsonBody = false,
): Promise<Record<string, string>> {
const configHeaders =
typeof this.config.headers === "function"
? await this.config.headers()
: this.config.headers || {};
const headers = normalizeHeaderRecord({
...configHeaders,
...customHeaders,
});
// Only set Content-Type for methods that can have a body
if (hasJsonBody && methodSupportsRequestBody(this.contract.method)) {
const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type",
);
if (!hasContentType) {
headers["content-type"] = "application/json";
}
}
return headers;
}
/**
* Create a contract error
*/
private createError(options: {
code: string;
message: string;
status?: number;
details?: unknown;
cause?: unknown;
body?: unknown;
response?: Response;
source?: ContractErrorSource;
}): AnyContractError {
const source = options.source ?? "client";
const isLocalError = source === "client" || source === "network";
return new ContractError({
source,
status: isLocalError ? undefined : options.status,
code: options.code,
message: options.message,
body: isLocalError ? undefined : options.body,
details: options.details,
response: isLocalError ? undefined : options.response,
cause: options.cause,
}) as AnyContractError;
}
}
/**
* Client for making contract-based requests.
*/
export class Client<TProvidedHeaders extends string = never> {
constructor(private config: ClientConfig<TProvidedHeaders>) {}
/**
* Create an endpoint wrapper for a contract.
*
* Accepts either a plain `HttpContractConfig` or a builder with a `.config`
* property.
*/
endpoint<TContractLike extends ContractLike>(
contract: TContractLike,
): Endpoint<ResolveContract<TContractLike>, TProvidedHeaders> {
const resolved = resolveContract(contract);
return new Endpoint(resolved, this.config);
}
}
/**
* Create a configured Beignet client.
*/
export function createClient<const TProvidedHeaders extends string = never>(
config: ClientConfig<TProvidedHeaders> = {},
): Client<TProvidedHeaders> {
return new Client(config);
}
async function parseResponseBody(response: Response): Promise<unknown> {
if (response.status === 204 || response.status === 205) {
return undefined;
}
const text = await response.text();
if (text === "") {
return undefined;
}
const contentType = response.headers.get("content-type");
if (contentType?.includes("application/json")) {
try {
return JSON.parse(text);
} catch (error) {
throw new MalformedResponseJsonError(error);
}
}
return text;
}
function createInvalidJsonMessage(
context: "success" | "error",
status: number,
): string {
return `Failed to parse JSON ${context} response (status ${status})`;
}
function createErrorResponseValidationMessage(
contract: HttpContractConfig,
status: number,
): string {
return `Error response validation failed for ${contract.method} ${contract.path} (status ${status}, contract: ${contract.name})`;
}
function hasFrameworkErrorOwner(response: Response): boolean {
return response.headers.get(BEIGNET_ERROR_OWNER_HEADER) === "framework";
}
async function validateErrorBodyOrStandardEnvelope(
contract: HttpContractConfig,
response: Response,
errorBody: unknown,
): Promise<unknown> {
const errorSchema = contract.responses[response.status];
const hasDeclaredResponses = Object.keys(contract.responses).length > 0;
if (errorSchema === null) {
if (errorBody === undefined || errorBody === null) {
return undefined;
}
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
return errorBody;
}
throw new ContractError({
source: "contract",
status: response.status,
code: "ERROR_RESPONSE_VALIDATION_ERROR",
message: createErrorResponseValidationMessage(contract, response.status),
details: [
{
message: "Response body must be empty for a null response schema.",
},
],
body: errorBody,
response,
});
}
if (!errorSchema) {
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
return errorBody;
}
if (hasDeclaredResponses) {
throw new ContractError({
source: "contract",
status: response.status,
code: "UNDECLARED_ERROR_STATUS",
message: `Server returned undeclared error status ${response.status} for ${contract.method} ${contract.path} (contract: ${contract.name})`,
body: errorBody,
response,
});
}
return errorBody;
}
try {
return await validateSchema(errorSchema, errorBody);
} catch (err) {
if (!(err instanceof SchemaValidationError)) {
throw err;
}
if (isErrorResponseBody(errorBody) && hasFrameworkErrorOwner(response)) {
return errorBody;
}
throw new ContractError({
source: "contract",
status: response.status,
code: "ERROR_RESPONSE_VALIDATION_ERROR",
message: createErrorResponseValidationMessage(contract, response.status),
details: err.issues,
cause: err,
body: errorBody,
response,
});
}
}
function getErrorPayload(body: unknown): {
code?: string;
message?: string;
details?: unknown;
} {
if (typeof body !== "object" || body === null) {
return {};
}
const payload = body as Record<string, unknown>;
return {
code: typeof payload.code === "string" ? payload.code : undefined,
message: typeof payload.message === "string" ? payload.message : undefined,
details: payload.details,
};
}