UNPKG

@alexop/openapi-zod-client

Version:

[![Screenshot 2022-11-12 at 18 52 25](https://user-images.githubusercontent.com/47224540/201487856-ffc4c862-6f31-4de1-8ef1-3981fabf3416.png)](https://openapi-zod-client.vercel.app/)

467 lines (402 loc) 20.5 kB
import type { ZodiosEndpointDefinition } from "@zodios/core"; import type { OpenAPIObject, OperationObject, ParameterObject, PathItemObject, ReferenceObject, RequestBodyObject, ResponseObject, SchemaObject, } from "openapi3-ts"; import type { ObjectLiteral } from "pastable"; import { match, P } from "ts-pattern"; import { sync } from "whence"; import type { CodeMeta, ConversionTypeContext } from "./CodeMeta"; import { getOpenApiDependencyGraph } from "./getOpenApiDependencyGraph"; import { isReferenceObject } from "./isReferenceObject"; import { makeSchemaResolver } from "./makeSchemaResolver"; import { getZodChain, getZodSchema } from "./openApiToZod"; import { getSchemaComplexity } from "./schema-complexity"; import type { TemplateContext } from "./template-context"; import { asComponentSchema, normalizeString, pathParamToVariableName, pathToVariableName, replaceHyphenatedPath, } from "./utils"; const voidSchema = "z.void()"; // eslint-disable-next-line sonarjs/cognitive-complexity export const getZodiosEndpointDefinitionList = (doc: OpenAPIObject, options?: TemplateContext["options"]) => { const resolver = makeSchemaResolver(doc); const graphs = getOpenApiDependencyGraph( Object.keys(doc.components?.schemas ?? {}).map((name) => asComponentSchema(name)), resolver.getSchemaByRef ); const endpoints = []; const isMainResponseStatus = match(options?.isMainResponseStatus) .with(P.string, (option) => (status: number) => sync(option, { status }, { functions: true })) .with(P.nullish, () => (status: number) => status >= 200 && status < 300) .otherwise((fn) => fn); const isErrorStatus = match(options?.isErrorStatus) .with(P.string, (option) => (status: number) => sync(option, { status }, { functions: true })) .with(P.nullish, () => (status: number) => !(status >= 200 && status < 300)) .otherwise((fn) => fn); const isMediaTypeAllowed = match(options?.isMediaTypeAllowed) .with(P.string, (option) => (mediaType: string) => sync(option, { mediaType }, { functions: true })) .with(P.nullish, () => (mediaType: string) => mediaType === "application/json") .otherwise((fn) => fn); const getOperationAlias = match(options?.withAlias) .with( P.boolean, P.nullish, () => (path: string, method: string, operation: OperationObject) => operation.operationId ?? method + pathToVariableName(path) ) .otherwise((fn) => fn); const ctx: ConversionTypeContext = { resolver, zodSchemaByName: {}, schemaByName: {} }; const complexityThreshold = options?.complexityThreshold ?? 4; const getZodVarName = (input: CodeMeta, fallbackName?: string) => { const result = input.toString(); // special value, inline everything (= no variable used) if (complexityThreshold === -1) { return input.ref ? ctx.zodSchemaByName[result]! : result; } if ((result.startsWith("z.") || input.ref === undefined) && fallbackName) { // result is simple enough that it doesn't need to be assigned to a variable if (input.complexity < complexityThreshold) { return result; } const safeName = normalizeString(fallbackName); // if schema is already assigned to a variable, re-use that variable name if (ctx.schemaByName[result]) { return ctx.schemaByName[result]!; } // result is complex and would benefit from being re-used let formatedName = safeName; // iteratively add suffix number to prevent overwriting let reuseCount = 1; let isVarNameAlreadyUsed = false; while ((isVarNameAlreadyUsed = Boolean(ctx.zodSchemaByName[formatedName]))) { if (isVarNameAlreadyUsed) { if (ctx.zodSchemaByName[formatedName] === safeName) { return formatedName; } else { reuseCount += 1; formatedName = `${safeName}__${reuseCount}`; } } } ctx.zodSchemaByName[formatedName] = result; ctx.schemaByName[result] = formatedName; return formatedName; } // result is a reference to another schema let schema = ctx.zodSchemaByName[result]; if (!schema && input.ref) { const refInfo = ctx.resolver.resolveRef(input.ref); schema = ctx.zodSchemaByName[refInfo.name]; } if (input.ref && schema) { const complexity = getSchemaComplexity({ current: 0, schema: ctx.resolver.getSchemaByRef(input.ref) }); // ref result is simple enough that it doesn't need to be assigned to a variable if (complexity < complexityThreshold) { return ctx.zodSchemaByName[result]!; } return result; } console.log({ ref: input.ref, fallbackName, result }); throw new Error("Invalid ref: " + input.ref); }; const defaultStatusBehavior = options?.defaultStatusBehavior ?? "spec-compliant"; const ignoredFallbackResponse = [] as string[]; const ignoredGenericError = [] as string[]; for (const path in doc.paths) { const pathItemObj = doc.paths[path] as PathItemObject; const pathItem = pick(pathItemObj, ["get", "put", "post", "delete", "options", "head", "patch", "trace"]); const parametersMap = getParametersMap(pathItemObj.parameters ?? []); for (const method in pathItem) { const operation = pathItem[method as keyof typeof pathItem] as OperationObject | undefined; if (!operation) continue; if (options?.withDeprecatedEndpoints ? false : operation.deprecated) continue; const parameters = Object.entries({ ...parametersMap, ...getParametersMap(operation.parameters ?? []), }).map(([_id, param]) => param); const operationName = getOperationAlias(path, method, operation); let endpointDefinition: EndpointDefinitionWithRefs = { method: method as EndpointDefinitionWithRefs["method"], path: replaceHyphenatedPath(path), ...(options?.withAlias && { alias: operationName }), description: operation.description, summary: operation.summary, requestFormat: "json", parameters: [], errors: [], response: "", }; if (options?.endpointDefinitionRefiner) { // Refine the endpoint definition, in case consumer wants to add some specific fields // to be rendered in the Handlebars template. endpointDefinition = options.endpointDefinitionRefiner(endpointDefinition, operation); } if (operation.requestBody) { const requestBody = ( isReferenceObject(operation.requestBody) ? ctx.resolver.getSchemaByRef(operation.requestBody.$ref) : operation.requestBody ) as RequestBodyObject; const mediaTypes = Object.keys(requestBody.content ?? {}); const matchingMediaType = mediaTypes.find(isAllowedParamMediaTypes); const bodySchema = matchingMediaType && requestBody.content?.[matchingMediaType]?.schema; if (bodySchema) { endpointDefinition.requestFormat = match(matchingMediaType) .with("application/octet-stream", () => "binary" as const) .with("application/x-www-form-urlencoded", () => "form-url" as const) .with("multipart/form-data", () => "form-data" as const) .with(P.string.includes("json"), () => "json" as const) .otherwise(() => "text" as const); const bodyCode = getZodSchema({ schema: bodySchema, ctx, meta: { isRequired: requestBody.required ?? true }, options, }); endpointDefinition.parameters.push({ name: "body", type: "Body", description: requestBody.description!, schema: getZodVarName(bodyCode, operationName + "_Body") + getZodChain({ schema: isReferenceObject(bodySchema) ? ctx.resolver.getSchemaByRef(bodySchema.$ref) : bodySchema, meta: bodyCode.meta, }), }); } } for (const param of parameters) { const paramItem = ( isReferenceObject(param) ? ctx.resolver.getSchemaByRef(param.$ref) : param ) as ParameterObject; if (allowedPathInValues.includes(paramItem.in)) { let paramSchema: SchemaObject | ReferenceObject | undefined; if (paramItem.content) { const mediaTypes = Object.keys(paramItem.content ?? {}); const matchingMediaType = mediaTypes.find(isAllowedParamMediaTypes); if (!matchingMediaType) { throw new Error( `Unsupported media type for param ${paramItem.name}: ${mediaTypes.join(", ")}` ); } const mediaTypeObject = paramItem.content[matchingMediaType]; if (!mediaTypeObject) { throw new Error( `No content with media type for param ${paramItem.name}: ${matchingMediaType}` ); } // this fallback is needed to autofix openapi docs that put the $ref in the wrong place // (it should be in the mediaTypeObject.schema, not in the mediaTypeObject itself) // https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.0.3.md#style-values (just above this anchor) // @ts-expect-error paramSchema = mediaTypeObject?.schema ?? mediaTypeObject; } else { paramSchema = isReferenceObject(paramItem.schema) ? ctx.resolver.getSchemaByRef(paramItem.schema.$ref) : paramItem.schema; } if (options?.withDescription && paramSchema) { (paramSchema as SchemaObject).description = (paramItem.description ?? "")?.replace("\n", ""); } // resolve ref if needed, and fallback to default (unknown) value if needed paramSchema = paramSchema ? (isReferenceObject(paramSchema) ? ctx.resolver.getSchemaByRef(paramSchema.$ref) : paramSchema)! : {}; const paramCode = getZodSchema({ schema: paramSchema ?? {}, ctx, meta: { isRequired: paramItem.in === "path" ? true : paramItem.required ?? false }, options, }); endpointDefinition.parameters.push({ name: match(paramItem.in) .with("path", () => pathParamToVariableName(paramItem.name)) .otherwise(() => paramItem.name), explode: paramItem.explode, style: paramItem.style, description: paramItem.description!, type: match(paramItem.in) .with("header", () => "Header") .with("query", () => "Query") .with("path", () => "Path") .run() as "Header" | "Query" | "Path", schema: getZodVarName( paramCode.assign( paramCode.toString() + getZodChain({ schema: paramSchema, meta: paramCode.meta, options }) ), paramItem.name ), }); } } for (const statusCode in operation.responses) { const responseItem = ( isReferenceObject(operation.responses[statusCode]) ? ctx.resolver.getSchemaByRef(operation.responses[statusCode].$ref) : operation.responses[statusCode] ) as ResponseObject; const mediaTypes = Object.keys(responseItem.content ?? {}); const matchingMediaType = mediaTypes.find(isMediaTypeAllowed); const maybeSchema = matchingMediaType ? responseItem.content?.[matchingMediaType]?.schema : null; let schemaString = matchingMediaType ? undefined : voidSchema; let schema: CodeMeta | undefined; if (maybeSchema) { schema = getZodSchema({ schema: maybeSchema, ctx, meta: { isRequired: true }, options }); schemaString = (schema.ref ? getZodVarName(schema) : schema.toString()) + getZodChain({ schema: isReferenceObject(maybeSchema) ? ctx.resolver.getSchemaByRef(maybeSchema.$ref) : maybeSchema, meta: schema.meta, }); } if (schemaString) { const status = Number(statusCode); if (isMainResponseStatus(status) && !endpointDefinition.response) { endpointDefinition.response = schemaString; if ( !endpointDefinition.description && responseItem.description && options?.useMainResponseDescriptionAsEndpointDefinitionFallback ) { endpointDefinition.description = responseItem.description; } } else if (statusCode !== "default" && isErrorStatus(status)) { endpointDefinition.errors.push({ schema: schemaString as any, status, description: responseItem.description, }); } } } // use `default` as fallback for `response` undeclared responses // if no main response has been found, this should be considered it as a fallback // else this will be added as an error response if (operation.responses?.default) { const responseItem = operation.responses.default as ResponseObject; const mediaTypes = Object.keys(responseItem.content ?? {}); const matchingMediaType = mediaTypes.find(isMediaTypeAllowed); const maybeSchema = matchingMediaType && responseItem.content?.[matchingMediaType]?.schema; let schemaString = matchingMediaType ? undefined : voidSchema; let schema: CodeMeta | undefined; if (maybeSchema) { schema = getZodSchema({ schema: maybeSchema, ctx, meta: { isRequired: true }, options }); schemaString = (schema.ref ? getZodVarName(schema) : schema.toString()) + getZodChain({ schema: isReferenceObject(maybeSchema) ? ctx.resolver.getSchemaByRef(maybeSchema.$ref) : maybeSchema, meta: schema.meta, }); } if (schemaString) { if (defaultStatusBehavior === "auto-correct") { if (endpointDefinition.response) { endpointDefinition.errors.push({ schema: schemaString as any, status: "default", description: responseItem.description, }); } else { endpointDefinition.response = schemaString; } } else { if (endpointDefinition.response) { ignoredFallbackResponse.push(operationName); } else { ignoredGenericError.push(operationName); } } } } if (!endpointDefinition.response) { endpointDefinition.response = voidSchema; } endpoints.push(endpointDefinition); } } if (options?.willSuppressWarnings !== true) { if (ignoredFallbackResponse.length > 0) { console.warn( `The following endpoints have no status code other than \`default\` and were ignored as the OpenAPI spec recommends. However they could be added by setting \`defaultStatusBehavior\` to \`auto-correct\`: ${ignoredGenericError.join( ", " )}` ); } if (ignoredGenericError.length > 0) { console.warn( `The following endpoints could have had a generic error response added by setting \`defaultStatusBehavior\` to \`auto-correct\` ${ignoredGenericError.join( ", " )}` ); } } return { ...(ctx as Required<ConversionTypeContext>), ...graphs, endpoints, issues: { ignoredFallbackResponse, ignoredGenericError, }, }; }; const getParametersMap = (parameters: NonNullable<PathItemObject["parameters"]>) => { return Object.fromEntries( (parameters ?? []).map((param) => [isReferenceObject(param) ? param.$ref : param.name, param] as const) ); }; const allowedPathInValues = ["query", "header", "path"] as Array<ParameterObject["in"]>; export type EndpointDefinitionWithRefs = Omit< ZodiosEndpointDefinition<any>, "response" | "parameters" | "errors" | "description" | "summary" > & { response: string; description?: string | undefined; summary?: string | undefined; parameters: Array< Omit<Required<ZodiosEndpointDefinition<any>>["parameters"][number], "schema"> & { schema: string } >; errors: Array<Omit<Required<ZodiosEndpointDefinition<any>>["errors"][number], "schema"> & { schema: string }>; }; const allowedParamMediaTypes = [ "application/octet-stream", "multipart/form-data", "application/x-www-form-urlencoded", "*/*", ] as const; const isAllowedParamMediaTypes = ( mediaType: string ): mediaType is typeof allowedParamMediaTypes[number] | `application/${string}json${string}` | `text/${string}` => (mediaType.includes("application/") && mediaType.includes("json")) || allowedParamMediaTypes.includes(mediaType as any) || mediaType.includes("text/"); /** Pick given properties in object */ function pick<T extends ObjectLiteral, K extends keyof T>(obj: T, paths: K[]): Pick<T, K> { const result = {} as Pick<T, K>; Object.keys(obj).forEach((key) => { if (!paths.includes(key as K)) return; // @ts-expect-error result[key] = obj[key]; }); return result; }