UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,237 lines (1,149 loc) 36.5 kB
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, }; }