UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

704 lines 26.8 kB
import { BEIGNET_ERROR_OWNER_HEADER, getContractHeaderSchemas, methodSupportsRequestBody, parsePathTemplate, resolveContract, } from "../contracts/index.js"; import { isErrorResponseBody, SchemaValidationError } from "../errors/index.js"; import { encodeQueryValue } from "../query-codec.js"; /** * 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 extends Error { /** * Error source category. */ source; /** * HTTP status when a response was available. */ status; /** * Stable error code. */ code; /** * Parsed response body when available. */ body; /** * Structured error details when available. */ details; /** * Native fetch response when available. */ response; cause; constructor(args) { super(args.message); this.name = "ContractError"; this.source = args.source; this.status = args.status; 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(status) { return this.status === status; } /** * Check whether this error came from a specific source. */ hasSource(source) { return this.source === source; } /** * Check whether this error has a specific error code. */ hasCode(code) { return this.code === code; } } export function isContractError(err, criteria) { 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) { return values.map((value) => `"${value}"`).join(", "); } class MalformedResponseJsonError extends Error { parseError; constructor(parseError) { 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() { 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, missing, provided) { 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(schema, data) { 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) { const normalized = {}; for (const [key, value] of Object.entries(headers)) { if (value !== undefined) { normalized[key.toLowerCase()] = value; } } return normalized; } function serializeParsedHeaders(parsed) { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return {}; } const headers = {}; for (const [key, value] of Object.entries(parsed)) { if (value !== undefined && value !== null) { headers[key.toLowerCase()] = String(value); } } return headers; } async function validateHeaderSchemas(schemas, headers) { let validatedHeaders = headers; for (const schema of schemas) { const parsed = await validateSchema(schema, headers); validatedHeaders = { ...validatedHeaders, ...serializeParsedHeaders(parsed), }; } return validatedHeaders; } /** * Typed client endpoint for one contract. */ export class Endpoint { contract; config; constructor(contract, config) { this.contract = contract; this.config = config; } isError(err, criteria) { return isContractError(err, criteria); } /** * Call the endpoint and return the parsed success body. * * Throws `ContractError` when the request fails or the response violates the * contract. */ async call(...callArgs) { 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) { const args = (callArgs[0] ?? {}); let phase = "client"; let response; let responseStatus; 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; let requestBodyType; 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)); } 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, args.query); let headers = await this.buildHeaders(args.headers, 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 = { 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; 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; 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, response, }; } catch (err) { if (err instanceof ContractError) { const error = err; 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 } : {}), }); return { ok: false, status: error.status, error, response: error.response, }; } } /** * Build the full URL with path and query parameters */ async buildUrl(path, query) { let parsedPath; 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)); } 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 let queryToSerialize = query; if (query) { // Validate query params if schema exists and validation is enabled if (this.config.validateInput && this.contract.query) { try { queryToSerialize = (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; } } const params = new URLSearchParams(); const normalizedQuery = queryToSerialize ?? query; for (const [key, value] of Object.entries(normalizedQuery)) { if (value !== undefined && value !== null) { params.append(key, this.serializeQueryParam(key, value)); } } 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; } serializeQueryParam(key, value) { try { return encodeQueryValue(value); } catch { throw this.invalidQueryParam(key, "contains a non-serializable value"); } } invalidQueryParam(key, reason) { return new ContractError({ source: "client", code: "INVALID_QUERY_PARAM", message: `Query parameter "${key}" ${reason}. Use primitive values, Dates, JSON-serializable objects, or arrays of those values.`, }); } /** * Build request headers */ async buildHeaders(customHeaders, hasJsonBody = false) { 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 */ createError(options) { 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, }); } } /** * Client for making contract-based requests. */ export class Client { config; constructor(config) { this.config = config; } /** * Create an endpoint wrapper for a contract. * * Accepts either a plain `HttpContractConfig` or a builder with a `.config` * property. */ endpoint(contract) { const resolved = resolveContract(contract); return new Endpoint(resolved, this.config); } } /** * Create a configured Beignet client. */ export function createClient(config = {}) { return new Client(config); } async function parseResponseBody(response) { 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, status) { return `Failed to parse JSON ${context} response (status ${status})`; } function createErrorResponseValidationMessage(contract, status) { return `Error response validation failed for ${contract.method} ${contract.path} (status ${status}, contract: ${contract.name})`; } function hasFrameworkErrorOwner(response) { return response.headers.get(BEIGNET_ERROR_OWNER_HEADER) === "framework"; } async function validateErrorBodyOrStandardEnvelope(contract, response, errorBody) { 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) { if (typeof body !== "object" || body === null) { return {}; } const payload = body; return { code: typeof payload.code === "string" ? payload.code : undefined, message: typeof payload.message === "string" ? payload.message : undefined, details: payload.details, }; } //# sourceMappingURL=client.js.map