UNPKG

fastify-zod-openapi

Version:
532 lines (531 loc) 16 kB
var __defProp = Object.defineProperty; var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import fp from "fastify-plugin"; import { createRegistry, isAnyZodType, unwrapZodObject, createComponents } from "zod-openapi/api"; import fastJsonStringify from "fast-json-stringify"; import { createSchema } from "zod-openapi"; import { createError } from "@fastify/error"; import { formatParamUrl } from "@fastify/swagger"; const FASTIFY_ZOD_OPENAPI_CONFIG = Symbol("fastify-zod-openapi-config"); const fastifyZodOpenApi = async (fastify, opts) => { const registry = createRegistry(opts.components); fastify.addHook("onRoute", ({ schema }) => { if (!schema || schema.hide) { return; } schema[FASTIFY_ZOD_OPENAPI_CONFIG] ?? (schema[FASTIFY_ZOD_OPENAPI_CONFIG] = { registry, documentOpts: opts.documentOpts, fastifyComponents: { responses: /* @__PURE__ */ new Map(), requestBodies: /* @__PURE__ */ new Map() } }); }); }; const fastifyZodOpenApiPlugin = fp(fastifyZodOpenApi, { name: "fastify-zod-openapi" }); class RequestValidationError extends Error { constructor(keyword, instancePath, schemaPath, message, params) { super(message, { cause: params.issue }); __publicField(this, "cause"); this.keyword = keyword; this.instancePath = instancePath; this.schemaPath = schemaPath; this.message = message; this.params = params; } } class ResponseSerializationError extends createError( "FST_ERR_RESPONSE_SERIALIZATION", "Response does not match the schema", 500 ) { constructor(method, url, options) { super(); __publicField(this, "cause"); this.method = method; this.url = url; this.cause = options.cause; } } const createSerializerCompiler = (opts) => (routeSchema) => { const { schema, url, method } = routeSchema; if (!isAnyZodType(schema)) { return (opts == null ? void 0 : opts.fallbackSerializer) ? opts.fallbackSerializer(routeSchema) : fastJsonStringify(schema); } let stringify = opts == null ? void 0 : opts.stringify; if (!stringify) { const { schema: jsonSchema, components } = createSchema(schema, { registry: createRegistry({ schemas: opts == null ? void 0 : opts.components }), schemaRefPath: "#/definitions/" }); const maybeDefinitions = components ? { definitions: components } : void 0; stringify = fastJsonStringify({ ...jsonSchema, ...maybeDefinitions }); } return (value) => { const result = schema.safeParse(value); if (!result.success) { throw new ResponseSerializationError(method, url, { cause: result.error }); } return stringify(result.data); }; }; const serializerCompiler = createSerializerCompiler(); const createParams = (parameters, type, registry, path) => { const params = {}; for (const [key, value] of Object.entries(parameters._zod.def.shape)) { const parameter = registry.addParameter(value, path, { location: { in: type, name: key } }); if ("$ref" in parameter || !parameter.schema) { throw new Error("References not supported"); } const { in: inLocation, name, schema, ...rest } = parameter; params[key] = rest; } return params; }; const createResponse = (response, contentTypes, registry, responseComponents, path) => { if (typeof response !== "object" || response == null) { return response; } const responsesObject = {}; for (const [key, value] of Object.entries(response)) { const unknownValue = value; if (isAnyZodType(unknownValue)) { if (!(contentTypes == null ? void 0 : contentTypes.length)) { responsesObject[key] = registry.addSchema( unknownValue, [...path, key, "content", "application/json", "schema"], { io: "output", source: { type: "mediaType" } } ); continue; } const contentSchemas = contentTypes.map( (contentType) => registry.addSchema( unknownValue, [...path, key, "content", contentType, "schema"], { io: "output", source: { type: "mediaType" } } ) ); responsesObject[key] = contentSchemas[0]; continue; } if (unknownValue && typeof unknownValue === "object" && !("content" in unknownValue)) { responsesObject[key] = { description: "description" in unknownValue ? unknownValue.description : void 0, type: "null" }; continue; } const responsePath = [...path, key]; const responseObject = registry.addResponse( unknownValue, responsePath ); if ("$ref" in responseObject && typeof responseObject.$ref === "string") { responseComponents.set(responseObject.$ref, { referenceObject: responseObject, path: responsePath }); } responsesObject[key] = responseObject; } return responsesObject; }; const createBody = (body, contentTypes, routePath, registry, bodyComponents) => { if (!body) { return void 0; } if (isAnyZodType(body)) { if (!(contentTypes == null ? void 0 : contentTypes.length)) { const bodySchema = registry.addSchema( body, [...routePath, "requestBody", "content", "application/json", "schema"], { io: "input", source: { type: "mediaType" } } ); bodySchema["x-fastify-zod-openapi-optional"] = body._zod.optin === "optional"; return bodySchema; } const bodySchemas = contentTypes.map((contentType) => { const schema = registry.addSchema( body, [...routePath, "requestBody", "content", contentType, "schema"], { io: "input", source: { type: "mediaType" } } ); schema["x-fastify-zod-openapi-optional"] = body._zod.optin === "optional"; return schema; }); return bodySchemas[0]; } const requestBodyPath = [...routePath, "requestBody"]; const requestBodyObject = registry.addRequestBody( body, requestBodyPath ); if ("$ref" in requestBodyObject && typeof requestBodyObject.$ref === "string") { bodyComponents.set(requestBodyObject.$ref, { referenceObject: requestBodyObject, path: requestBodyPath }); } return requestBodyObject; }; const fastifyZodOpenApiTransform = ({ schema, url, ...opts }) => { var _a; if (!schema || schema.hide) { return { schema, url }; } const { response, headers, querystring, body, params, ...rest } = schema; if (!("openapiObject" in opts)) { throw new Error("openapiObject was not found in the options"); } const config = schema[FASTIFY_ZOD_OPENAPI_CONFIG]; if (!config) { throw new Error("Please register the fastify-zod-openapi plugin"); } const { registry } = config; (_a = opts.openapiObject)[FASTIFY_ZOD_OPENAPI_CONFIG] ?? (_a[FASTIFY_ZOD_OPENAPI_CONFIG] = config); const fastifySchema = rest; const routeMethods = typeof opts.route.method === "string" ? [opts.route.method] : opts.route.method; const routes = routeMethods.map((method) => { var _a2, _b; const routeObject = {}; let urlWithoutPrefix = url; const serverUrl = ((_b = (((_a2 = opts == null ? void 0 : opts.openapiObject) == null ? void 0 : _a2.servers) ?? []).find( (server) => server.url.startsWith("/") )) == null ? void 0 : _b.url) || "/"; if (serverUrl !== "/") { const maybeSubPath = url.split(serverUrl)[1]; if (maybeSubPath) { urlWithoutPrefix = formatParamUrl(maybeSubPath); } } const routePath = [ "paths", formatParamUrl(urlWithoutPrefix), method.toLowerCase() ]; const parameterPath = [...routePath, "parameters"]; const maybeBody = createBody( body, rest.consumes, routePath, registry, config.fastifyComponents.requestBodies ); if (maybeBody) { routeObject.body = maybeBody; } const maybeResponse = createResponse( response, rest.produces, registry, config.fastifyComponents.responses, [...routePath, "responses"] ); if (maybeResponse) { routeObject.response = maybeResponse; } if (isAnyZodType(querystring)) { const queryStringSchema = unwrapZodObject(querystring, "input", [ ...parameterPath, "query" ]); routeObject.querystring = createParams( queryStringSchema, "query", registry, parameterPath ); } if (isAnyZodType(params)) { const paramsSchema = unwrapZodObject(params, "input", [ ...parameterPath, "path" ]); routeObject.params = createParams( paramsSchema, "path", registry, parameterPath ); } if (isAnyZodType(headers)) { const headersSchema = unwrapZodObject(headers, "input", [ ...parameterPath, "header" ]); routeObject.headers = createParams( headersSchema, "header", registry, parameterPath ); } return routeObject; }); Object.assign(fastifySchema, routes[0]); return { schema: fastifySchema, url }; }; const resolveSchemaComponent = (object, registry) => { if (typeof object.$ref === "string") { const id = object.$ref.replace("#/components/schemas/", ""); return registry.components.schemas.ids.get(id); } return object; }; const traverseObject = (openapiObject, source, schemaObject, registry) => { var _a, _b, _c, _d; let index = 0; let current = openapiObject; while (index < source.path.length) { const key = source.path[index++]; if (typeof current !== "object" || current === null || !(key in current)) { return void 0; } current = current[key]; if (typeof current === "object" && current !== null && "$ref" in current && typeof current.$ref === "string") { return current; } if (key === "requestBody" && typeof current === "object") { const requestBody = current; if (source.type === "requestBody") { delete requestBody.content; delete requestBody.required; delete requestBody.description; Object.assign(requestBody, schemaObject); return requestBody; } const contentType = (_a = source.path) == null ? void 0 : _a[index + 1]; if (!contentType) { return void 0; } const schema = (_c = (_b = requestBody.content) == null ? void 0 : _b[contentType]) == null ? void 0 : _c.schema; if (!schema) { return void 0; } if ("$ref" in schema && schema.$ref) { return schema; } const resolved = resolveSchemaComponent(schemaObject, registry); const description = schemaObject.description ?? resolved.description; if (description) { requestBody.description = description; } const { examples, ...schemaWithoutExamples } = schemaObject; Object.assign(schema, schemaWithoutExamples); if (examples !== void 0 && Array.isArray(examples) && examples.length > 0) { const examplesAsRecord = examples.reduce((result, example, examplesIndex) => { result[`Example${examplesIndex + 1}`] = { value: example }; return result; }, {}); if (requestBody.content[contentType]) { requestBody.content[contentType].examples = examplesAsRecord; } } if (schema["x-fastify-zod-openapi-optional"] === false) { requestBody.required = true; delete schema["x-fastify-zod-openapi-optional"]; } return schema; } if (key === "parameters" && typeof current === "object" && Array.isArray(current) && source.type === "parameter") { const parameter = current.find( (param) => param.name === source.location.name && param.in === source.location.in ); if (parameter == null ? void 0 : parameter.schema) { return Object.assign( parameter.schema, schemaObject ); } return void 0; } if (key === "responses" && typeof current === "object" && source.type === "response") { const responses = current; const statusCode = (_d = source.path) == null ? void 0 : _d[index]; if (!statusCode) { return void 0; } responses[statusCode] = schemaObject; return responses[statusCode]; } } if (typeof current === "object" && current !== null && Object.keys(current).length !== 0) { return current; } return Object.assign(current, schemaObject); }; const combineComponents = (existingComponents, newComponents) => { const allComponents = [ "schemas", "parameters", "responses", "requestBodies", "securitySchemes", "examples", "links", "headers", "callbacks" ]; for (const key of allComponents) { if (existingComponents[key] || newComponents[key]) { existingComponents[key] = { ...existingComponents[key], ...newComponents[key] }; } } return existingComponents; }; const fastifyZodOpenApiTransformObject = (opts) => { if ("swaggerObject" in opts) { return opts.swaggerObject; } const config = opts.openapiObject[FASTIFY_ZOD_OPENAPI_CONFIG]; if (!config) { return opts.openapiObject; } const components = createComponents( config.registry, config.documentOpts ?? {}, opts.openapiObject.openapi ); for (const [, value] of config.fastifyComponents.responses) { traverseObject( opts.openapiObject, { type: "response", path: value.path }, value.referenceObject, config.registry ); } for (const [, value] of config.fastifyComponents.requestBodies) { traverseObject( opts.openapiObject, { type: "requestBody", path: value.path }, value.referenceObject, config.registry ); } for (const [, value] of config.registry.components.schemas.input) { traverseObject( opts.openapiObject, value.source, value.schemaObject, config.registry ); } for (const [, value] of config.registry.components.schemas.output) { traverseObject( opts.openapiObject, value.source, value.schemaObject, config.registry ); } return { ...opts.openapiObject, components: combineComponents( opts.openapiObject.components, components ) }; }; const fastifyZodOpenApiTransformers = { transform: fastifyZodOpenApiTransform, transformObject: fastifyZodOpenApiTransformObject }; const validatorCompiler = ({ schema }) => { if (!isAnyZodType(schema)) { return (value) => ({ value }); } return (value) => { const result = schema.safeParse(value); if (!result.success) { return { error: result.error.issues.map( (issue) => new RequestValidationError( issue.code, `/${issue.path.join("/")}`, `#/${issue.path.join("/")}/${issue.code}`, issue.message, { issue, error: result.error } ) ) // Types are wrong https://github.com/fastify/fastify/pull/5787 }; } return { value: result.data }; }; }; export { FASTIFY_ZOD_OPENAPI_CONFIG, RequestValidationError, ResponseSerializationError, createSerializerCompiler, fastifyZodOpenApiPlugin, fastifyZodOpenApiTransform, fastifyZodOpenApiTransformObject, fastifyZodOpenApiTransformers, serializerCompiler, validatorCompiler };