UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

480 lines 17.3 kB
/** * @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