fastify-zod-openapi
Version:
Fastify plugin for zod-openapi
532 lines (531 loc) • 16 kB
JavaScript
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
};