UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

627 lines (607 loc) 16.6 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import { assertErrorsAvoidCustomResponseStatuses, assertResponsesAvoidCatalogErrorStatuses, mergeCatalogErrors, responsesFromErrors, } from "./catalog-errors.js"; import { assertContractQueryTransport } from "./contract-like.js"; import { assertValidContractDeprecation, assertValidContractLifecycle, type ContractDeprecationMeta, } from "./lifecycle.js"; import { mergeContractMeta } from "./metadata.js"; import type { OpenAPIOperationMeta } from "./openapi-meta.js"; import { parsePathTemplate } from "./path-template.js"; import type { QueryTransport, QueryTransportCompatibility, } from "./query-transport.js"; import type { BodyHttpMethod, ContractErrorResponses, ContractHeaderSchemas, ContractMeta, ContractResponses, HttpContractConfig, HttpMethod, MergeContractMeta, MergedContractErrorResponses, OmitMetaKeys, ResponsesFromErrorDefinitions, StandardSchema, } from "./types.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< TMethod extends HttpMethod, TPathParams extends StandardSchema | null, TQuery extends StandardSchema | null, TBody extends StandardSchema | null, THeaders extends ContractHeaderSchemas, TResponses extends ContractResponses, TMeta extends ContractMeta, TPath extends string = string, > { readonly kind: "http"; readonly name: string; readonly namespace?: string; readonly localName: string; readonly method: TMethod; private readonly _path: TPath; private readonly _pathParams: TPathParams; private readonly _query: TQuery; private readonly _queryTransport: QueryTransport | null; private readonly _body: TBody; private readonly _headers: THeaders; private readonly _responses: TResponses; private readonly _meta: TMeta; constructor( config: HttpContractConfig< TMethod, TPathParams, TQuery, TBody, TResponses, TMeta, TPath, THeaders >, ) { assertValidContractLifecycle(config); assertContractQueryTransport(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._queryTransport = config.queryTransport ?? null; this._body = config.body; this._headers = config.headers ?? (null as THeaders); 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(): TResponses { return this._responses; } /** * URL path template for this contract. */ get path(): TPath { return this._path; } /** * Metadata consumed by hooks, OpenAPI generation, and app conventions. */ get metadata(): TMeta { return this._meta; } /** * Plain contract config consumed by framework internals and integrations. */ get config(): HttpContractConfig< TMethod, TPathParams, TQuery, TBody, TResponses, TMeta, TPath, THeaders > { 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, queryTransport: this._queryTransport, 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<TNewPathParams extends StandardSchemaV1>( schema: TNewPathParams, ): ContractBuilder< TMethod, TNewPathParams, TQuery, TBody, THeaders, TResponses, TMeta, TPath > { return new ContractBuilder({ kind: this.kind, name: this.name, namespace: this.namespace, localName: this.localName, method: this.method, path: this._path, pathParams: schema as unknown as TNewPathParams, query: this._query, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: this._responses, metadata: this._meta, }); } /** * Attach a query schema and the deterministic URL transport for its input. * * The schema owns validation, defaults, and transforms. The transport owns * client encoding, server decoding, and OpenAPI serialization. */ query< TNewQuery extends StandardSchemaV1, const TTransport extends QueryTransport, >( schema: TNewQuery, transport: TTransport & QueryTransportCompatibility< StandardSchemaV1.InferInput<TNewQuery>, TTransport >, ): ContractBuilder< TMethod, TPathParams, TNewQuery, TBody, THeaders, TResponses, TMeta, TPath > { 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 as unknown as TNewQuery, queryTransport: transport, 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<TNewBody extends StandardSchemaV1>( this: ContractBuilder< BodyHttpMethod, StandardSchema | null, StandardSchema | null, StandardSchema | null, ContractHeaderSchemas, ContractResponses, ContractMeta, TPath >, schema: TNewBody, ): ContractBuilder< TMethod, TPathParams, TQuery, TNewBody, THeaders, TResponses, TMeta, TPath > { const self = this as unknown as ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, TResponses, TMeta, TPath >; 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, queryTransport: self._queryTransport, headers: self._headers, body: schema as unknown as TNewBody, 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<TNewHeaders extends StandardSchemaV1>( schema: TNewHeaders, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders extends readonly StandardSchema[] ? readonly [...THeaders, TNewHeaders] : THeaders extends StandardSchema ? readonly [THeaders, TNewHeaders] : readonly [TNewHeaders], TResponses, TMeta, TPath > { 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, queryTransport: this._queryTransport, headers: [ ...existingHeaders, schema, ] as unknown as THeaders extends readonly StandardSchema[] ? readonly [...THeaders, TNewHeaders] : THeaders extends StandardSchema ? readonly [THeaders, TNewHeaders] : readonly [TNewHeaders], 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<TNewResponses extends ContractResponses>( responseSchemas: TNewResponses, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, Omit<TResponses, keyof TNewResponses> & TNewResponses, TMeta, TPath > { 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, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: { ...this._responses, ...responseSchemas, } as unknown as Omit<TResponses, keyof TNewResponses> & TNewResponses, 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<TErrorDefs extends ContractErrorResponses>( errorDefs: TErrorDefs, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, Omit<TResponses, keyof ResponsesFromErrorDefinitions<TErrorDefs>> & ResponsesFromErrorDefinitions<TErrorDefs>, OmitMetaKeys<TMeta, "errors"> & { errors: MergedContractErrorResponses<TMeta, TErrorDefs>; }, TPath > { 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, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: { ...this._responses, ...errorResponses, } as unknown as Omit< TResponses, keyof ResponsesFromErrorDefinitions<TErrorDefs> > & ResponsesFromErrorDefinitions<TErrorDefs>, metadata: { ...this._meta, errors: mergeCatalogErrors(this._meta, errorDefs), } as unknown as OmitMetaKeys<TMeta, "errors"> & { errors: MergedContractErrorResponses<TMeta, TErrorDefs>; }, }); } /** * 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<TNewMeta extends ContractMeta>( newMeta: TNewMeta, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, TResponses, MergeContractMeta<TMeta, TNewMeta>, TPath > { 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, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, newMeta), }); } /** * Mark this contract as deprecated for external clients. */ deprecated<const TDeprecation extends ContractDeprecationMeta>( deprecation: TDeprecation, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, TResponses, MergeContractMeta<TMeta, { deprecation: TDeprecation }>, TPath > { 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, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, { deprecation }), }); } /** * Merge OpenAPI operation metadata into this contract. */ openapi<TPatch extends Partial<OpenAPIOperationMeta>>( patch: TPatch, ): ContractBuilder< TMethod, TPathParams, TQuery, TBody, THeaders, TResponses, MergeContractMeta<TMeta, { openapi: TPatch }>, TPath > { 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, queryTransport: this._queryTransport, headers: this._headers, body: this._body, responses: this._responses, metadata: mergeContractMeta(this._meta, { openapi: patch }), }); } } /** * Options for creating one contract with `defineContract(...)`. */ export type DefineContractOptions< TMethod extends HttpMethod = HttpMethod, TPath extends string = string, > = { /** HTTP method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS) */ method: TMethod; /** URL path template (e.g., "/api/users/:id") */ path: TPath; /** Optional contract name (auto-generated from method + path if not provided) */ name?: string; }; /** * 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< TMethod extends HttpMethod, const TPath extends string, >( options: DefineContractOptions<TMethod, TPath>, ): ContractBuilder< TMethod, null, null, null, null, Record<never, never>, ContractMeta, TPath > { 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, queryTransport: null, headers: null, body: null, responses: {}, metadata: {}, }); }