UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,050 lines (952 loc) 24.6 kB
/** * @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; }