@beignet/core
Version:
Core framework primitives for Beignet
704 lines • 26.8 kB
JavaScript
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