UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

354 lines 11.4 kB
import { assertErrorsAvoidCustomResponseStatuses, assertResponsesAvoidCatalogErrorStatuses, mergeCatalogErrors, responsesFromErrors, } from "./catalog-errors.js"; import { assertValidContractDeprecation, assertValidContractLifecycle, } from "./lifecycle.js"; import { mergeContractMeta } from "./metadata.js"; import { parsePathTemplate } from "./path-template.js"; import { methodSupportsRequestBody } from "./types.js"; import { generateContractName } from "./utils.js"; /** * Fluent builder for one HTTP contract. * * A contract describes the transport boundary for one endpoint: method, path, * request schemas, response schemas, metadata, and route-owned catalog errors. * Builder methods are immutable; each call returns a new builder with narrower * types. */ export class ContractBuilder { kind; name; namespace; localName; method; _path; _pathParams; _query; _body; _headers; _responses; _meta; constructor(config) { assertValidContractLifecycle(config); if (config.body && !methodSupportsRequestBody(config.method)) { throw new Error(`Request bodies are not supported for ${config.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`); } this.kind = config.kind; this.name = config.name; this.namespace = config.namespace; this.localName = config.localName ?? config.name; this.method = config.method; this._path = config.path; this._pathParams = config.pathParams; this._query = config.query; this._body = config.body; this._headers = config.headers ?? null; this._responses = config.responses; this._meta = config.metadata; } /** * Request and response schemas attached to this contract. * * Server adapters use these for validation. Client and frontend integrations * use them for local validation and type inference. */ get schema() { return { pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: this._responses, }; } /** * Response schemas keyed by HTTP status code. */ get responseSchemas() { return this._responses; } /** * URL path template for this contract. */ get path() { return this._path; } /** * Metadata consumed by hooks, OpenAPI generation, and app conventions. */ get metadata() { return this._meta; } /** * Plain contract config consumed by framework internals and integrations. */ get config() { return { kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: this._responses, metadata: this._meta, }; } /** * Attach a schema for dynamic path parameters. * * The schema validates parameters parsed from path templates such as * `/posts/:id` or `/posts/[id]`. */ pathParams(schema) { return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: schema, query: this._query, headers: this._headers, body: this._body, responses: this._responses, metadata: this._meta, }); } /** * Attach a schema for query parameters. */ query(schema) { return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: schema, headers: this._headers, body: this._body, responses: this._responses, metadata: this._meta, }); } /** * Attach a schema for a JSON request body. * * This method is only available on POST, PUT, and PATCH contracts. */ body(schema) { const self = this; return new ContractBuilder({ kind: self.kind, name: self.name, namespace: self.namespace, localName: self.localName, method: self.method, path: self._path, pathParams: self._pathParams, query: self._query, headers: self._headers, body: schema, responses: self._responses, metadata: self._meta, }); } /** * Attach a request header schema. * * Multiple schemas are evaluated in declaration order and their parsed * outputs are merged. This lets a contract inherit shared group headers and * still declare route-specific headers. */ headers(schema) { const existingHeaders = this._headers ? Array.isArray(this._headers) ? this._headers : [this._headers] : []; return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: [ ...existingHeaders, schema, ], body: this._body, responses: this._responses, metadata: this._meta, }); } /** * Add or replace route-owned response schemas by status code. * * These schemas describe business responses returned by route handlers. * Framework-owned errors such as request validation failures do not need to be * declared here. * * Use `null` for void/empty responses such as 204 No Content. */ responses(responseSchemas) { assertResponsesAvoidCatalogErrorStatuses(this._meta, responseSchemas); return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: { ...this._responses, ...responseSchemas, }, metadata: this._meta, }); } /** * Declare route-owned application errors from an error catalog. * * Catalog errors use Beignet's standard error response envelope and remain * distinguishable from framework-owned errors. Declarations merge with * previously declared catalog errors, including shared group errors; later * declarations win when the same catalog key is declared twice. Use * `.responses()` when a route needs a custom error response body instead of * catalog semantics. */ errors(errorDefs) { const errorResponses = responsesFromErrors(errorDefs); assertErrorsAvoidCustomResponseStatuses(this._meta, this._responses, errorResponses); return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: { ...this._responses, ...errorResponses, }, metadata: { ...this._meta, errors: mergeCatalogErrors(this._meta, errorDefs), }, }); } /** * Merge metadata into this contract. * * Hooks and tooling can read metadata for concerns such as auth, rate limits, * idempotency, OpenAPI, or app-specific conventions. */ meta(newMeta) { return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, newMeta), }); } /** * Mark this contract as deprecated for external clients. */ deprecated(deprecation) { assertValidContractDeprecation(deprecation, this.name); return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, { deprecation }), }); } /** * Merge OpenAPI operation metadata into this contract. */ openapi(patch) { return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: this._pathParams, query: this._query, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, { openapi: patch }), }); } } /** * Create a new HTTP contract builder. * * Most apps prefer `defineContractGroup().namespace(...).prefix(...)` for * related feature contracts. Use this lower-level factory when a standalone * contract is clearer. * * @example * ```ts * const getTodo = defineContract({ * method: "GET", * path: "/api/todos/:id", * }) * .pathParams(z.object({ id: z.string() })) * .responses({ 200: TodoSchema }); * ``` * * @param options - HTTP method, path template, and optional contract name. * @returns A fluent contract builder. */ export function defineContract(options) { parsePathTemplate(options.path); const name = options.name || generateContractName(options.method, options.path); return new ContractBuilder({ kind: "http", name, localName: name, method: options.method, path: options.path, pathParams: null, query: null, headers: null, body: null, responses: {}, metadata: {}, }); } //# sourceMappingURL=contract-builder.js.map