UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

507 lines (478 loc) 13.4 kB
import { assertErrorsAvoidCustomResponseStatuses, assertResponsesAvoidCatalogErrorStatuses, mergeCatalogErrors, responsesFromErrors, } from "./catalog-errors.js"; import { ContractBuilder } from "./contract-builder.js"; import { assertValidContractDeprecation, type ContractDeprecationMeta, } from "./lifecycle.js"; import { mergeContractMeta } from "./metadata.js"; import { parsePathTemplate } from "./path-template.js"; import type { ContractErrorResponses, ContractHeaderSchemas, ContractMeta, ContractResponses, HttpMethod, MergeContractMeta, MergedContractErrorResponses, OmitMetaKeys, ResponsesFromErrorDefinitions, StandardSchema, } from "./types.js"; import { generateContractName } from "./utils.js"; type TrimLeadingSlash<T extends string> = T extends `/${infer Rest}` ? TrimLeadingSlash<Rest> : T; type TrimTrailingSlash<T extends string> = T extends "/" ? "" : T extends `${infer Rest}/` ? TrimTrailingSlash<Rest> : T; type NormalizedPrefix<T extends string> = T extends "" | "/" ? "" : TrimTrailingSlash<T>; type NormalizedChildPath<T extends string> = TrimTrailingSlash< TrimLeadingSlash<T> >; type JoinPaths<TPrefix extends string, TPath extends string> = string extends | TPrefix | TPath ? string : NormalizedPrefix<TPrefix> extends "" ? TPath : NormalizedChildPath<TPath> extends "" ? NormalizedPrefix<TPrefix> : `${NormalizedPrefix<TPrefix>}/${NormalizedChildPath<TPath>}`; function joinPathPrefix(prefix: string, path: string): string { const segments = [ ...prefix.split("/").filter(Boolean), ...path.split("/").filter(Boolean), ]; return `/${segments.join("/")}`; } /** * Feature-scoped factory for related HTTP contracts. * * Contract groups let a feature share a namespace, path prefix, headers, * responses, errors, and metadata across multiple endpoint contracts. The group * is immutable: every configuration method returns a new group. */ export class ContractGroup< TSharedResponses extends ContractResponses = Record<never, never>, TSharedMeta extends ContractMeta = ContractMeta, TSharedHeaders extends ContractHeaderSchemas = null, TPathPrefix extends string = "", > { private readonly _namespace: string; private readonly _meta: TSharedMeta; private readonly _responses: TSharedResponses; private readonly _headers: TSharedHeaders; private readonly _pathPrefix: TPathPrefix; constructor( state: { namespace?: string; meta?: TSharedMeta; responses?: TSharedResponses; headers?: TSharedHeaders; pathPrefix?: TPathPrefix; } = {} as { namespace?: string; meta?: TSharedMeta; responses?: TSharedResponses; headers?: TSharedHeaders; pathPrefix?: TPathPrefix; }, ) { this._namespace = state.namespace ?? ""; this._meta = state.meta ?? ({} as TSharedMeta); this._responses = state.responses ?? ({} as TSharedResponses); this._headers = state.headers ?? (null as TSharedHeaders); this._pathPrefix = state.pathPrefix ?? ("" as TPathPrefix); } /** * Set the namespace for contracts created from this group. * * The namespace prefixes contract names, not paths. Use `prefix(...)` for * path composition. */ namespace( ns: string, ): ContractGroup<TSharedResponses, TSharedMeta, TSharedHeaders, TPathPrefix> { return new ContractGroup({ namespace: ns, meta: this._meta, responses: this._responses, headers: this._headers, pathPrefix: this._pathPrefix, }); } /** * Add a path prefix to contracts created from this group. * * Prefixes compose immutably, so `prefix("/api").prefix("/v1")` produces * paths under `/api/v1`. */ prefix<const TNewPrefix extends string>( pathPrefix: TNewPrefix, ): ContractGroup< TSharedResponses, TSharedMeta, TSharedHeaders, JoinPaths<TPathPrefix, TNewPrefix> > { parsePathTemplate(pathPrefix); const nextPrefix = joinPathPrefix(this._pathPrefix, pathPrefix); const normalizedPrefix = nextPrefix === "/" ? "" : nextPrefix; return new ContractGroup({ namespace: this._namespace, meta: this._meta, responses: this._responses, headers: this._headers, pathPrefix: normalizedPrefix as JoinPaths<TPathPrefix, TNewPrefix>, }); } /** * Merge shared metadata into contracts created from this group. */ meta<TNewMeta extends ContractMeta>( meta: TNewMeta, ): ContractGroup< TSharedResponses, MergeContractMeta<TSharedMeta, TNewMeta>, TSharedHeaders, TPathPrefix > { return new ContractGroup({ namespace: this._namespace, meta: mergeContractMeta(this._meta, meta), responses: this._responses, headers: this._headers, pathPrefix: this._pathPrefix, }); } /** * Mark every contract created from this group as deprecated. */ deprecated<const TDeprecation extends ContractDeprecationMeta>( deprecation: TDeprecation, ): ContractGroup< TSharedResponses, MergeContractMeta<TSharedMeta, { deprecation: TDeprecation }>, TSharedHeaders, TPathPrefix > { assertValidContractDeprecation(deprecation, this._namespace || "group"); return new ContractGroup({ namespace: this._namespace, meta: mergeContractMeta(this._meta, { deprecation }), responses: this._responses, headers: this._headers, pathPrefix: this._pathPrefix, }); } /** * Add shared route-owned response schemas to contracts created from this group. * * Framework-owned responses, such as validation or auth hook failures, do not * need to be declared here. */ responses<TNewResponses extends ContractResponses>( responseSchemas: TNewResponses, ): ContractGroup< Omit<TSharedResponses, keyof TNewResponses> & TNewResponses, TSharedMeta, TSharedHeaders, TPathPrefix > { assertResponsesAvoidCatalogErrorStatuses(this._meta, responseSchemas); return new ContractGroup({ namespace: this._namespace, meta: this._meta, responses: { ...this._responses, ...responseSchemas } as unknown as Omit< TSharedResponses, keyof TNewResponses > & TNewResponses, headers: this._headers, pathPrefix: this._pathPrefix, }); } /** * Declare shared route-owned application errors for contracts in this group. * * Catalog errors use Beignet's standard error envelope and remain separate * from framework-owned errors. Declarations merge with previously declared * group errors, and contracts created from the group merge these shared * errors with route-level `.errors()` declarations; later declarations win * when the same catalog key is declared twice. */ errors<TErrorDefs extends ContractErrorResponses>( errorDefs: TErrorDefs, ): ContractGroup< Omit<TSharedResponses, keyof ResponsesFromErrorDefinitions<TErrorDefs>> & ResponsesFromErrorDefinitions<TErrorDefs>, OmitMetaKeys<TSharedMeta, "errors"> & { errors: MergedContractErrorResponses<TSharedMeta, TErrorDefs>; }, TSharedHeaders, TPathPrefix > { const errorResponses = responsesFromErrors(errorDefs); assertErrorsAvoidCustomResponseStatuses( this._meta, this._responses, errorResponses, ); return new ContractGroup({ namespace: this._namespace, meta: { ...this._meta, errors: mergeCatalogErrors(this._meta, errorDefs), } as unknown as OmitMetaKeys<TSharedMeta, "errors"> & { errors: MergedContractErrorResponses<TSharedMeta, TErrorDefs>; }, responses: { ...this._responses, ...errorResponses } as unknown as Omit< TSharedResponses, keyof ResponsesFromErrorDefinitions<TErrorDefs> > & ResponsesFromErrorDefinitions<TErrorDefs>, headers: this._headers, pathPrefix: this._pathPrefix, }); } /** * Add a shared request header schema to contracts created from this group. */ headers<TNewHeaders extends StandardSchema>( schema: TNewHeaders, ): ContractGroup< TSharedResponses, TSharedMeta, TSharedHeaders extends readonly StandardSchema[] ? readonly [...TSharedHeaders, TNewHeaders] : TSharedHeaders extends StandardSchema ? readonly [TSharedHeaders, TNewHeaders] : readonly [TNewHeaders], TPathPrefix > { const existingHeaders = this._headers ? Array.isArray(this._headers) ? this._headers : [this._headers] : []; return new ContractGroup({ namespace: this._namespace, meta: this._meta, responses: this._responses, headers: [ ...existingHeaders, schema, ] as unknown as TSharedHeaders extends readonly StandardSchema[] ? readonly [...TSharedHeaders, TNewHeaders] : TSharedHeaders extends StandardSchema ? readonly [TSharedHeaders, TNewHeaders] : readonly [TNewHeaders], pathPrefix: this._pathPrefix, }); } /** * Create a GET contract under this group. */ get<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "GET", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("GET", path, name); } /** * Create a POST contract under this group. */ post<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "POST", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("POST", path, name); } /** * Create a PUT contract under this group. */ put<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "PUT", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("PUT", path, name); } /** * Create a PATCH contract under this group. */ patch<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "PATCH", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("PATCH", path, name); } /** * Create a DELETE contract under this group. */ delete<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "DELETE", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("DELETE", path, name); } /** * Create a HEAD contract under this group. */ head<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "HEAD", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("HEAD", path, name); } /** * Create an OPTIONS contract under this group. */ options<const TPath extends string>( path: TPath, name?: string, ): ContractBuilder< "OPTIONS", null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { return this.createBuilder("OPTIONS", path, name); } /** * Internal helper to create a contract builder with shared config */ private createBuilder<TMethod extends HttpMethod, const TPath extends string>( method: TMethod, path: TPath, name?: string, ): ContractBuilder< TMethod, null, null, null, TSharedHeaders, TSharedResponses, TSharedMeta, JoinPaths<TPathPrefix, TPath> > { parsePathTemplate(path); const fullPath = ( this._pathPrefix ? joinPathPrefix(this._pathPrefix, path) : path ) as JoinPaths<TPathPrefix, TPath>; parsePathTemplate(fullPath); const contractName = name || generateContractName(method, fullPath); const fullName = this._namespace ? `${this._namespace}.${contractName}` : contractName; return new ContractBuilder({ kind: "http", name: fullName, namespace: this._namespace || undefined, localName: contractName, method, path: fullPath, pathParams: null, query: null, headers: this._headers, body: null, responses: { ...this._responses } as unknown as TSharedResponses, metadata: { ...this._meta } as unknown as TSharedMeta, }); } } /** * Create a new feature contract group. * * Start here for most feature HTTP surfaces, then add a namespace and path * prefix before defining individual contracts. * * @example * ```ts * const todos = defineContractGroup() * .namespace("todos") * .prefix("/api/todos") * .meta({ auth: "required" }) * .responses({ * 401: z.object({ message: z.literal("Unauthorized") }), * }); * * const getTodo = todos.get("/:id")... * ``` * * @returns An empty immutable contract group. */ export function defineContractGroup(): ContractGroup< Record<never, never>, ContractMeta, null, "" > { return new ContractGroup(); }