@beignet/core
Version:
Core framework primitives for Beignet
627 lines (607 loc) • 16.6 kB
text/typescript
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: {},
});
}