@beignet/core
Version:
Core framework primitives for Beignet
480 lines • 17.3 kB
JavaScript
/**
* @beignet/core/openapi
*
* OpenAPI 3.1 generation from Beignet contracts
*/
import { 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, } from "./schema-introspector.js";
// Re-export the introspector types for consumers who want custom implementations
export { createZodIntrospector, createZodSchemaConverter, } from "./schema-introspector.js";
/**
* 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, options) {
const paths = {};
const components = {
schemas: {},
securitySchemes: options.securitySchemes ?? {},
};
const state = {
components,
jsonMediaType: options.jsonMediaType ?? "application/json",
introspector: options.schemaIntrospector ?? createZodIntrospector(),
schemaConverters: [
...(options.schemaConverters ?? []),
createZodSchemaConverter(),
],
};
const operationIds = new Map();
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 = {
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, paths, state) {
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 = {
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();
pathItem[methodKey] = operation;
}
function applyOpenAPIOverrides(operation, meta) {
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, parameter) {
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, operation, state) {
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 = {
name: key,
in: "path",
required: true,
schema: paramSchemaRef,
description,
};
addParameter(operation, param);
}
}
/**
* Add query parameters from contract to operation.
*/
function addQueryParams(contract, operation, state) {
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 = {
name: key,
in: "query",
required: !optional,
schema: paramSchemaRef,
description,
};
addParameter(operation, param);
}
}
/**
* Add header parameters from contract to operation.
*/
function addHeaderParams(contract, operation, state) {
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, optional, state) {
return (state.introspector.getDescription(field) ??
(optional
? state.introspector.getDescription(state.introspector.unwrapOptional(field))
: undefined));
}
/**
* Add request body from contract to operation
*/
function addRequestBody(contract, operation, state) {
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, operation, state) {
// 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;
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 } : {}),
},
},
};
}
}
function getCatalogErrorsForStatus(contract, status) {
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.code !== "string" ||
typeof error.status !== "number" ||
typeof error.message !== "string") {
return undefined;
}
const catalogError = {
key,
code: error.code,
status: error.status,
message: error.message,
};
const details = error.details;
if (details !== undefined) {
catalogError.details = details;
}
return catalogError;
})
.filter((error) => error !== undefined && error.status === status);
}
function catalogErrorResponseSchemaRef(errors, nameHint, state) {
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, nameHint, state) {
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) {
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) {
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, nameHint, state, io) {
// 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, nameHint, state, io) {
if (schema === STANDARD_ERROR_RESPONSE_SCHEMA) {
return standardErrorResponseSchemaRef(nameHint, state);
}
return schemaToConvertedSchemaRef(schema, nameHint, state, io);
}
function standardErrorResponseSchemaRef(nameHint, state) {
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, state) {
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;
}
//# sourceMappingURL=index.js.map