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