@beignet/core
Version:
Core framework primitives for Beignet
1,050 lines (952 loc) • 24.6 kB
text/typescript
/**
* @beignet/core/openapi
*
* OpenAPI 3.1 generation from Beignet contracts
*/
import {
type AnyContract,
type ContractDeprecationMeta,
type ContractLike,
getContractHeaderSchemas,
methodSupportsRequestBody,
parsePathTemplate,
resolveContract,
STANDARD_ERROR_RESPONSE_SCHEMA,
} from "../contracts/index.js";
import {
assertValidContractLifecycle,
getContractOperationId,
} from "../contracts/lifecycle.js";
import {
comparePathParamsToTemplate,
formatPathParamsMismatch,
} from "../contracts/schema-shape.js";
import {
createZodIntrospector,
createZodSchemaConverter,
type SchemaConverter,
type SchemaIntrospector,
type SchemaIO,
} from "./schema-introspector.js";
// Re-export the introspector types for consumers who want custom implementations
export {
createZodIntrospector,
createZodSchemaConverter,
type SchemaConverter,
type SchemaIntrospector,
} from "./schema-introspector.js";
/**
* OpenAPI 3.1 info object.
*/
export interface OpenAPIInfo {
/**
* API title.
*/
title: string;
/**
* API version.
*/
version: string;
/**
* API description.
*/
description?: string;
/**
* Terms of service URL.
*/
termsOfService?: string;
/**
* API contact information.
*/
contact?: {
name?: string;
url?: string;
email?: string;
};
/**
* API license information.
*/
license?: {
name: string;
url?: string;
};
}
/**
* OpenAPI 3.1 server object.
*/
export interface OpenAPIServer {
/**
* Server URL.
*/
url: string;
/**
* Server description.
*/
description?: string;
/**
* Server URL variables.
*/
variables?: Record<
string,
{
default: string;
enum?: string[];
description?: string;
}
>;
}
/**
* OpenAPI 3.1 security scheme object.
*/
export type OpenAPISecurityScheme =
| {
type: "apiKey";
name: string;
in: "query" | "header" | "cookie";
description?: string;
}
| {
type: "http";
scheme: string;
bearerFormat?: string;
description?: string;
}
| {
type: "oauth2";
flows: Record<string, unknown>;
description?: string;
}
| {
type: "openIdConnect";
openIdConnectUrl: string;
description?: string;
};
/**
* OpenAPI schema object.
*/
export type SchemaObject = Record<string, unknown>;
/**
* OpenAPI reference object.
*/
export type ReferenceObject = { $ref: string };
/**
* OpenAPI parameter object.
*/
export interface ParameterObject {
/**
* Parameter name.
*/
name: string;
/**
* Parameter location.
*/
in: "path" | "query" | "header" | "cookie";
/**
* Whether the parameter is required.
*/
required?: boolean;
/**
* Parameter schema.
*/
schema?: SchemaObject | ReferenceObject;
/**
* Parameter description.
*/
description?: string;
/**
* Whether the parameter is deprecated.
*/
deprecated?: boolean;
}
/**
* OpenAPI request body object.
*/
export interface RequestBodyObject {
/**
* Whether the request body is required.
*/
required?: boolean;
/**
* Request body description.
*/
description?: string;
/**
* Request body content keyed by media type.
*/
content: Record<
string,
{
schema?: SchemaObject | ReferenceObject;
}
>;
}
/**
* OpenAPI response object.
*/
export interface ResponseObject {
/**
* Response description.
*/
description: string;
/**
* Response content keyed by media type.
*/
content?: Record<
string,
{
schema?: SchemaObject | ReferenceObject;
examples?: Record<
string,
{
summary?: string;
value?: unknown;
}
>;
}
>;
}
/**
* OpenAPI operation object.
*/
export interface OperationObject {
/**
* Operation ID.
*/
operationId?: string;
/**
* Operation summary.
*/
summary?: string;
/**
* Operation description.
*/
description?: string;
/**
* Operation tags.
*/
tags?: string[];
/**
* Whether the operation is deprecated.
*/
deprecated?: boolean;
/** Beignet lifecycle details for a deprecated operation. */
"x-beignet-deprecation"?: ContractDeprecationMeta;
/**
* External documentation.
*/
externalDocs?: {
description?: string;
url: string;
};
/**
* Operation-specific security requirements.
*/
security?: Array<Record<string, string[]>>;
/**
* Operation parameters.
*/
parameters?: ParameterObject[];
/**
* Operation request body.
*/
requestBody?: RequestBodyObject;
/**
* Operation responses keyed by status code.
*/
responses: Record<string, ResponseObject>;
}
/**
* OpenAPI path item object.
*/
export interface PathItemObject {
get?: OperationObject;
post?: OperationObject;
put?: OperationObject;
patch?: OperationObject;
delete?: OperationObject;
head?: OperationObject;
options?: OperationObject;
}
/**
* OpenAPI paths object.
*/
export type PathsObject = Record<string, PathItemObject>;
/**
* OpenAPI components object.
*/
export interface ComponentsObject {
/**
* Reusable schemas keyed by component name.
*/
schemas?: Record<string, SchemaObject>;
/**
* Reusable security schemes keyed by scheme name.
*/
securitySchemes?: Record<string, OpenAPISecurityScheme>;
}
/**
* OpenAPI 3.1 document.
*/
export interface OpenAPIObject {
/**
* OpenAPI version.
*/
openapi: "3.1.0";
/**
* API info.
*/
info: OpenAPIInfo;
/**
* Server list.
*/
servers?: OpenAPIServer[];
/**
* API paths.
*/
paths: PathsObject;
/**
* Reusable components.
*/
components?: ComponentsObject;
/**
* Global security requirements.
*/
security?: Array<Record<string, string[]>>;
}
/**
* Options for generating an OpenAPI document.
*/
export interface OpenAPIGeneratorOptions {
/** API title */
title: string;
/** API version */
version: string;
/** API description */
description?: string;
/** Server configurations */
servers?: OpenAPIServer[];
/** Media type for JSON content (default: application/json) */
jsonMediaType?: string;
/** Security schemes for authentication */
securitySchemes?: Record<string, OpenAPISecurityScheme>;
/** Global security requirements */
security?: Array<Record<string, string[]>>;
/**
* Schema introspector for reading metadata from schema objects.
* Defaults to a Zod introspector. Provide a custom implementation
* to support other schema libraries.
*/
schemaIntrospector?: SchemaIntrospector;
/**
* Schema converters used to turn contract schemas into OpenAPI schemas.
* Custom converters run before Beignet's default Zod converter.
*/
schemaConverters?: readonly SchemaConverter[];
}
/**
* Internal state for generator
*/
type GeneratorState = {
components: ComponentsObject;
jsonMediaType: string;
introspector: SchemaIntrospector;
schemaConverters: readonly SchemaConverter[];
};
/**
* Contract input accepted by the OpenAPI generator.
*/
export type ContractInput = ContractLike;
/**
* Convert contracts to an OpenAPI 3.1 document.
*
* @param contracts - Array of HTTP contracts (ContractBuilder instances or configs)
* @param options - OpenAPI generation options
* @returns OpenAPI 3.1 document object
*
* @example
* ```ts
* import { contractsToOpenAPI } from "@beignet/core/openapi";
* import { getTodo, listTodos } from "./contracts";
*
* const spec = contractsToOpenAPI(
* [getTodo, listTodos],
* {
* title: "Todo API",
* version: "1.0.0",
* servers: [{ url: "https://api.example.com" }],
* }
* );
* ```
*/
export function contractsToOpenAPI(
contracts: readonly ContractInput[],
options: OpenAPIGeneratorOptions,
): OpenAPIObject {
const paths: PathsObject = {};
const components: ComponentsObject = {
schemas: {},
securitySchemes: options.securitySchemes ?? {},
};
const state: GeneratorState = {
components,
jsonMediaType: options.jsonMediaType ?? "application/json",
introspector: options.schemaIntrospector ?? createZodIntrospector(),
schemaConverters: [
...(options.schemaConverters ?? []),
createZodSchemaConverter(),
],
};
const operationIds = new Map<string, string>();
for (const contract of contracts) {
const config = resolveContract(contract);
assertValidContractLifecycle(config);
const operationId = getContractOperationId(config);
const route = `${config.method} ${config.path}`;
const conflictingRoute = operationIds.get(operationId);
if (conflictingRoute) {
throw new Error(
`Duplicate OpenAPI operationId: "${operationId}" is used by both ${conflictingRoute} and ${route}. Operation IDs must be unique within an OpenAPI document.`,
);
}
operationIds.set(operationId, route);
addContractToPaths(config, paths, state);
}
const openapi: OpenAPIObject = {
openapi: "3.1.0",
info: {
title: options.title,
version: options.version,
description: options.description,
},
servers: options.servers,
paths,
components:
Object.keys(components.schemas ?? {}).length > 0 ||
Object.keys(components.securitySchemes ?? {}).length > 0
? components
: undefined,
security: options.security,
};
return openapi;
}
// =============================================================================
// OpenAPI Generation Helpers
// =============================================================================
/**
* Add a single contract to the paths object
*/
function addContractToPaths(
contract: AnyContract,
paths: PathsObject,
state: GeneratorState,
): void {
const pathKey = parsePathTemplate(contract.path).openApiPath;
if (!paths[pathKey]) {
paths[pathKey] = {};
}
const pathItem = paths[pathKey];
const meta = contract.metadata?.openapi;
const deprecation = contract.metadata?.deprecation;
const operation: OperationObject = {
operationId: getContractOperationId(contract),
summary: meta?.summary,
description: meta?.description,
tags: meta?.tags,
deprecated: deprecation ? true : meta?.deprecated,
"x-beignet-deprecation": deprecation,
externalDocs: meta?.externalDocs,
security: meta?.security,
parameters: [],
responses: {},
};
addPathParams(contract, operation, state);
addQueryParams(contract, operation, state);
addHeaderParams(contract, operation, state);
addRequestBody(contract, operation, state);
addResponses(contract, operation, state);
applyOpenAPIOverrides(operation, meta);
// Clean up empty parameters array
if (operation.parameters?.length === 0) {
delete operation.parameters;
}
const methodKey = contract.method.toLowerCase() as
| "get"
| "post"
| "put"
| "patch"
| "delete"
| "head"
| "options";
pathItem[methodKey] = operation;
}
function applyOpenAPIOverrides(
operation: OperationObject,
meta: AnyContract["metadata"]["openapi"] | undefined,
): void {
if (!meta) return;
for (const parameter of meta.parameters ?? []) {
addParameter(operation, parameter);
}
if (meta.requestBody) {
operation.requestBody = meta.requestBody;
}
for (const [status, response] of Object.entries(meta.responses ?? {})) {
operation.responses[status] = response;
}
}
function addParameter(
operation: OperationObject,
parameter: ParameterObject,
): void {
if (!operation.parameters) {
operation.parameters = [];
}
const existingIndex = operation.parameters.findIndex(
(existing) =>
existing.in === parameter.in && existing.name === parameter.name,
);
if (existingIndex >= 0) {
operation.parameters[existingIndex] = parameter;
return;
}
operation.parameters.push(parameter);
}
/**
* Add path parameters from contract to operation.
*/
function addPathParams(
contract: AnyContract,
operation: OperationObject,
state: GeneratorState,
): void {
const pathKeys = parsePathTemplate(contract.path).keys;
if (!contract.pathParams) {
for (const key of pathKeys) {
addParameter(operation, {
name: key,
in: "path",
required: true,
schema: { type: "string" },
});
}
return;
}
const shape = state.introspector.getShape(contract.pathParams);
if (!shape) {
for (const key of pathKeys) {
addParameter(operation, {
name: key,
in: "path",
required: true,
schema: { type: "string" },
});
}
return;
}
const { missingKeys, extraKeys } = comparePathParamsToTemplate({
pathKeys,
shapeKeys: Object.keys(shape),
});
if (missingKeys.length > 0 || extraKeys.length > 0) {
const details = formatPathParamsMismatch({ missingKeys, extraKeys });
throw new Error(
`Path parameters for contract "${contract.name}" must match "${contract.path}" (${details}).`,
);
}
for (const key of pathKeys) {
const field = shape[key];
const paramSchemaRef = schemaToConvertedSchemaRef(
field,
`${contract.name}_path_${key}`,
state,
"input",
);
const description = state.introspector.getDescription(field);
const param: ParameterObject = {
name: key,
in: "path",
required: true,
schema: paramSchemaRef,
description,
};
addParameter(operation, param);
}
}
/**
* Add query parameters from contract to operation.
*/
function addQueryParams(
contract: AnyContract,
operation: OperationObject,
state: GeneratorState,
): void {
if (!contract.query) return;
const shape = state.introspector.getShape(contract.query);
if (!shape) return;
for (const key of Object.keys(shape)) {
const originalField = shape[key];
const optional = state.introspector.isOptional(originalField);
const field = originalField;
const description = parameterDescription(field, optional, state);
const paramSchemaRef = schemaToConvertedSchemaRef(
field,
`${contract.name}_query_${key}`,
state,
"input",
);
const param: ParameterObject = {
name: key,
in: "query",
required: !optional,
schema: paramSchemaRef,
description,
};
addParameter(operation, param);
}
}
/**
* Add header parameters from contract to operation.
*/
function addHeaderParams(
contract: AnyContract,
operation: OperationObject,
state: GeneratorState,
): void {
const headerSchemas = getContractHeaderSchemas(contract.headers);
for (const headerSchema of headerSchemas) {
const shape = state.introspector.getShape(headerSchema);
if (!shape) continue;
for (const key of Object.keys(shape)) {
const originalField = shape[key];
const optional = state.introspector.isOptional(originalField);
const description = parameterDescription(originalField, optional, state);
const paramSchemaRef = schemaToConvertedSchemaRef(
originalField,
`${contract.name}_header_${key}`,
state,
"input",
);
addParameter(operation, {
name: key,
in: "header",
required: !optional,
schema: paramSchemaRef,
description,
});
}
}
}
function parameterDescription(
field: unknown,
optional: boolean,
state: GeneratorState,
): string | undefined {
return (
state.introspector.getDescription(field) ??
(optional
? state.introspector.getDescription(
state.introspector.unwrapOptional(field),
)
: undefined)
);
}
/**
* Add request body from contract to operation
*/
function addRequestBody(
contract: AnyContract,
operation: OperationObject,
state: GeneratorState,
): void {
if (!contract.body) return;
if (!methodSupportsRequestBody(contract.method)) {
throw new Error(
`Request bodies are not supported for ${contract.method} contracts. Use POST, PUT, or PATCH for contract request bodies.`,
);
}
const schemaRef = schemaToConvertedSchemaRef(
contract.body,
`${contract.name}_body`,
state,
"input",
);
const description = state.introspector.getDescription(contract.body);
operation.requestBody = {
required: true,
description,
content: {
[state.jsonMediaType]: {
schema: schemaRef,
},
},
};
}
/**
* Add responses from contract to operation
*/
function addResponses(
contract: AnyContract,
operation: OperationObject,
state: GeneratorState,
): void {
// Process all responses (both success and error status codes)
for (const [statusKey, zodSchema] of Object.entries(contract.responses)) {
const status = statusKey; // Keep as string
const catalogErrors = getCatalogErrorsForStatus(contract, Number(status));
// null schema means void/empty response (e.g. .responses({ 204: null }))
const hasSchema = zodSchema != null;
const schemaRef = hasSchema
? catalogErrors.length > 0 && zodSchema === STANDARD_ERROR_RESPONSE_SCHEMA
? catalogErrorResponseSchemaRef(
catalogErrors,
`${contract.name}_response_${status}`,
state,
)
: schemaToSchemaRef(
zodSchema,
`${contract.name}_response_${status}`,
state,
"output",
)
: undefined;
const described = hasSchema
? state.introspector.getDescription(zodSchema)
: undefined;
let description: string;
if (described) {
description = described;
} else if (catalogErrors.length === 1) {
description = catalogErrors[0].message;
} else if (catalogErrors.length > 1) {
description = catalogErrors.map((error) => error.message).join("; ");
} else if (status === "200") {
description = "OK";
} else if (status === "201") {
description = "Created";
} else if (status === "204") {
description = "No Content";
} else if (status === "400") {
description = "Bad Request";
} else if (status === "401") {
description = "Unauthorized";
} else if (status === "403") {
description = "Forbidden";
} else if (status === "404") {
description = "Not Found";
} else if (status === "500") {
description = "Internal Server Error";
} else {
description = `HTTP ${status}`;
}
const examples = examplesFromCatalogErrors(catalogErrors);
operation.responses[status] = {
description,
content:
!hasSchema || status === "204"
? undefined
: {
[state.jsonMediaType]: {
schema: schemaRef,
...(examples ? { examples } : {}),
},
},
};
}
}
type CatalogErrorForOpenAPI = {
key: string;
code: string;
status: number;
message: string;
details?: unknown;
};
function getCatalogErrorsForStatus(
contract: AnyContract,
status: number,
): CatalogErrorForOpenAPI[] {
const errors = contract.metadata?.errors;
if (typeof errors !== "object" || errors === null) return [];
return Object.entries(errors)
.map(([key, error]) => {
if (
typeof error !== "object" ||
error === null ||
typeof (error as { code?: unknown }).code !== "string" ||
typeof (error as { status?: unknown }).status !== "number" ||
typeof (error as { message?: unknown }).message !== "string"
) {
return undefined;
}
const catalogError: CatalogErrorForOpenAPI = {
key,
code: (error as { code: string }).code,
status: (error as { status: number }).status,
message: (error as { message: string }).message,
};
const details = (error as { details?: unknown }).details;
if (details !== undefined) {
catalogError.details = details;
}
return catalogError;
})
.filter(
(error): error is CatalogErrorForOpenAPI =>
error !== undefined && error.status === status,
);
}
function catalogErrorResponseSchemaRef(
errors: CatalogErrorForOpenAPI[],
nameHint: string,
state: GeneratorState,
): SchemaObject | ReferenceObject {
const schema =
errors.length === 1
? catalogErrorResponseSchema(errors[0], nameHint, state)
: {
oneOf: errors.map((error) =>
catalogErrorResponseSchema(
error,
`${nameHint}_${error.key}`,
state,
),
),
};
if (!state.components.schemas) {
state.components.schemas = {};
}
const schemaName = normalizeSchemaName(nameHint, state);
if (!state.components.schemas[schemaName]) {
state.components.schemas[schemaName] = schema;
}
return { $ref: `#/components/schemas/${schemaName}` };
}
function catalogErrorResponseSchema(
error: CatalogErrorForOpenAPI,
nameHint: string,
state: GeneratorState,
): SchemaObject {
return {
type: "object",
properties: {
code: { type: "string", const: error.code },
message: { type: "string" },
...(error.details
? {
details: schemaToSchemaRef(
error.details,
`${nameHint}_details`,
state,
"output",
),
}
: { details: {} }),
requestId: { type: "string" },
},
required: ["code", "message"],
};
}
function examplesFromCatalogErrors(
errors: CatalogErrorForOpenAPI[],
): Record<string, { summary?: string; value?: unknown }> | undefined {
if (errors.length === 0) return undefined;
return Object.fromEntries(
errors.map((error) => [
normalizeExampleKey(error.key),
{
summary: error.message,
value: {
code: error.code,
message: error.message,
},
},
]),
);
}
function normalizeExampleKey(key: string): string {
const normalized = key.replace(/[^A-Za-z0-9._-]/g, "_");
return normalized || "error";
}
/**
* Convert a validation schema to a JSON Schema reference, registering it in components.
*/
function schemaToConvertedSchemaRef(
schema: unknown,
nameHint: string,
state: GeneratorState,
io: SchemaIO,
): SchemaObject | ReferenceObject {
// Ensure schemas object exists
if (!state.components.schemas) {
state.components.schemas = {};
}
const schemaName = normalizeSchemaName(nameHint, state);
if (!state.components.schemas[schemaName]) {
const converter = state.schemaConverters.find((candidate) =>
candidate.canConvert(schema),
);
if (!converter) {
throw new Error(
`Unable to convert schema "${nameHint}" to OpenAPI. ` +
"Pass a schemaConverters entry to contractsToOpenAPI for this schema library.",
);
}
const jsonSchema = converter.toJSONSchema(schema, {
nameHint,
io,
});
state.components.schemas[schemaName] = jsonSchema;
}
return { $ref: `#/components/schemas/${schemaName}` };
}
function schemaToSchemaRef(
schema: unknown,
nameHint: string,
state: GeneratorState,
io: SchemaIO,
): SchemaObject | ReferenceObject {
if (schema === STANDARD_ERROR_RESPONSE_SCHEMA) {
return standardErrorResponseSchemaRef(nameHint, state);
}
return schemaToConvertedSchemaRef(schema, nameHint, state, io);
}
function standardErrorResponseSchemaRef(
nameHint: string,
state: GeneratorState,
): ReferenceObject {
if (!state.components.schemas) {
state.components.schemas = {};
}
const schemaName = normalizeSchemaName(nameHint, state);
if (!state.components.schemas[schemaName]) {
state.components.schemas[schemaName] = {
type: "object",
properties: {
code: { type: "string" },
message: { type: "string" },
details: {},
requestId: { type: "string" },
},
required: ["code", "message"],
};
}
return { $ref: `#/components/schemas/${schemaName}` };
}
/**
* Normalize schema name for use in components.
* Appends a counter suffix only when the base name already exists.
*/
function normalizeSchemaName(name: string, state: GeneratorState): string {
const base = name.replace(/[^A-Za-z0-9]/g, "_");
// Check if base name is already used
if (!state.components.schemas?.[base]) {
return base;
}
// Find a unique name by appending a counter
let counter = 1;
let uniqueName = `${base}_${counter}`;
while (state.components.schemas[uniqueName]) {
counter++;
uniqueName = `${base}_${counter}`;
}
return uniqueName;
}