diff --git a/package.json b/package.json index 29f52a071c4a2..78def4d2b2bfc 100644 --- a/package.json +++ b/package.json @@ -1742,7 +1742,7 @@ "xmlbuilder": "13.0.2", "yargs": "^15.4.1", "yarn-deduplicate": "^6.0.2", - "zod-to-json-schema": "^3.22.3" + "zod-to-json-schema": "^3.23.0" }, "packageManager": "yarn@1.22.21" } \ No newline at end of file diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/index.ts b/packages/kbn-router-to-openapispec/src/oas_converter/index.ts index 92bdd7a795a03..c7f2e0e41691e 100644 --- a/packages/kbn-router-to-openapispec/src/oas_converter/index.ts +++ b/packages/kbn-router-to-openapispec/src/oas_converter/index.ts @@ -10,10 +10,15 @@ import type { OpenAPIV3 } from 'openapi-types'; import { KnownParameters, OpenAPIConverter } from '../type'; import { kbnConfigSchemaConverter } from './kbn_config_schema'; +import { zodConverter } from './zod'; import { catchAllConverter } from './catch_all'; export class OasConverter { - readonly #converters: OpenAPIConverter[] = [kbnConfigSchemaConverter, catchAllConverter]; + readonly #converters: OpenAPIConverter[] = [ + kbnConfigSchemaConverter, + zodConverter, + catchAllConverter, + ]; readonly #sharedSchemas = new Map(); #getConverter(schema: unknown) { diff --git a/packages/kbn-router-to-openapispec/src/oas_converter/zod.ts b/packages/kbn-router-to-openapispec/src/oas_converter/zod.ts new file mode 100644 index 0000000000000..64b8faa368d6f --- /dev/null +++ b/packages/kbn-router-to-openapispec/src/oas_converter/zod.ts @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { z } from 'zod'; +import type { OpenAPIV3 } from 'openapi-types'; +// eslint-disable-next-line import/no-extraneous-dependencies +import zodToJsonSchema from 'zod-to-json-schema'; +import { KnownParameters, OpenAPIConverter } from '../type'; +import { validatePathParameters } from './common'; + +// Adapted from from https://github.com/jlalmes/trpc-openapi/blob/aea45441af785518df35c2bc173ae2ea6271e489/src/utils/zod.ts#L1 + +const instanceofZodType = (type: any): type is z.ZodTypeAny => { + return !!type?._def?.typeName; +}; + +const createError = (message: string): Error => { + return new Error(`[Zod converter] ${message}`); +}; + +function assertInstanceOfZodType(schema: unknown): asserts schema is z.ZodTypeAny { + if (!instanceofZodType(schema)) { + throw createError('Expected schema to be an instance of Zod'); + } +} + +const instanceofZodTypeKind = ( + type: z.ZodTypeAny, + zodTypeKind: Z +): type is InstanceType => { + return type?._def?.typeName === zodTypeKind; +}; + +const instanceofZodTypeObject = (type: z.ZodTypeAny): type is z.ZodObject => { + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodObject); +}; + +type ZodTypeLikeVoid = z.ZodVoid | z.ZodUndefined | z.ZodNever; + +const instanceofZodTypeLikeVoid = (type: z.ZodTypeAny): type is ZodTypeLikeVoid => { + return ( + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodVoid) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUndefined) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNever) + ); +}; + +const unwrapZodType = (type: z.ZodTypeAny, unwrapPreprocess: boolean): z.ZodTypeAny => { + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodOptional)) { + return unwrapZodType(type.unwrap(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDefault)) { + return unwrapZodType(type.removeDefault(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLazy)) { + return unwrapZodType(type._def.getter(), unwrapPreprocess); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { + if (type._def.effect.type === 'refinement') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + if (type._def.effect.type === 'transform') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + if (unwrapPreprocess && type._def.effect.type === 'preprocess') { + return unwrapZodType(type._def.schema, unwrapPreprocess); + } + } + return type; +}; + +interface NativeEnumType { + [k: string]: string | number; + [nu: number]: string; +} + +type ZodTypeLikeString = + | z.ZodString + | z.ZodOptional + | z.ZodDefault + | z.ZodEffects + | z.ZodUnion<[ZodTypeLikeString, ...ZodTypeLikeString[]]> + | z.ZodIntersection + | z.ZodLazy + | z.ZodLiteral + | z.ZodEnum<[string, ...string[]]> + | z.ZodNativeEnum; + +const instanceofZodTypeLikeString = (_type: z.ZodTypeAny): _type is ZodTypeLikeString => { + const type = unwrapZodType(_type, false); + + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEffects)) { + if (type._def.effect.type === 'preprocess') { + return true; + } + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodUnion)) { + return !type._def.options.some((option) => !instanceofZodTypeLikeString(option)); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodArray)) { + return instanceofZodTypeLikeString(type._def.type); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodIntersection)) { + return ( + instanceofZodTypeLikeString(type._def.left) && instanceofZodTypeLikeString(type._def.right) + ); + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodLiteral)) { + return typeof type._def.value === 'string'; + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodEnum)) { + return true; + } + if (instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNativeEnum)) { + return !Object.values(type._def.values).some((value) => typeof value === 'number'); + } + return instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodString); +}; + +const zodSupportsCoerce = 'coerce' in z; + +type ZodTypeCoercible = z.ZodNumber | z.ZodBoolean | z.ZodBigInt | z.ZodDate; + +const instanceofZodTypeCoercible = (_type: z.ZodTypeAny): _type is ZodTypeCoercible => { + const type = unwrapZodType(_type, false); + return ( + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodNumber) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBoolean) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodBigInt) || + instanceofZodTypeKind(type, z.ZodFirstPartyTypeKind.ZodDate) + ); +}; + +const convertObjectMembersToParameterObjects = ( + shape: z.ZodRawShape, + isRequired: boolean, + isPathParameter = false, + knownParameters: KnownParameters = {} +): OpenAPIV3.ParameterObject[] => { + return Object.entries(shape).map(([shapeKey, subShape]) => { + const isSubShapeRequired = !subShape.isOptional(); + + if (!instanceofZodTypeLikeString(subShape)) { + if (zodSupportsCoerce) { + if (!instanceofZodTypeCoercible(subShape)) { + throw createError( + `Input parser key: "${shapeKey}" must be ZodString, ZodNumber, ZodBoolean, ZodBigInt or ZodDate` + ); + } + } else { + throw createError(`Input parser key: "${shapeKey}" must be ZodString`); + } + } + + const { + schema: { description, ...openApiSchemaObject }, + } = convert(subShape); + + return { + name: shapeKey, + in: isPathParameter ? 'path' : 'query', + required: isPathParameter ? !knownParameters[shapeKey]?.optional : isSubShapeRequired, + schema: openApiSchemaObject, + description, + }; + }); +}; + +const convertQuery = (schema: unknown) => { + assertInstanceOfZodType(schema); + const unwrappedSchema = unwrapZodType(schema, true); + if (!instanceofZodTypeObject(unwrappedSchema)) { + throw createError('Query schema must be an _object_ schema validator!'); + } + const shape = unwrappedSchema.shape; + const isRequired = !schema.isOptional(); + return { + query: convertObjectMembersToParameterObjects(shape, isRequired), + shared: {}, + }; +}; + +const convertPathParameters = (schema: unknown, knownParameters: KnownParameters) => { + assertInstanceOfZodType(schema); + const unwrappedSchema = unwrapZodType(schema, true); + const paramKeys = Object.keys(knownParameters); + const paramsCount = paramKeys.length; + if (paramsCount === 0 && instanceofZodTypeLikeVoid(unwrappedSchema)) { + return { params: [], shared: {} }; + } + if (!instanceofZodTypeObject(unwrappedSchema)) { + throw createError('Parameters schema must be an _object_ schema validator!'); + } + const shape = unwrappedSchema.shape; + const schemaKeys = Object.keys(shape); + validatePathParameters(paramKeys, schemaKeys); + const isRequired = !schema.isOptional(); + return { + params: convertObjectMembersToParameterObjects(shape, isRequired, true), + shared: {}, + }; +}; + +const convert = (schema: z.ZodTypeAny) => { + return { + shared: {}, + schema: zodToJsonSchema(schema, { + target: 'openApi3', + $refStrategy: 'none', + }) as OpenAPIV3.SchemaObject, + }; +}; + +const is = instanceofZodType; + +export const zodConverter: OpenAPIConverter = { + convertPathParameters, + convertQuery, + convert, + is, +}; diff --git a/yarn.lock b/yarn.lock index 45616dcfbc56e..972f15bd8eeab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32488,6 +32488,11 @@ zod-to-json-schema@^3.22.3, zod-to-json-schema@^3.22.5: resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.22.5.tgz#3646e81cfc318dbad2a22519e5ce661615418673" integrity sha512-+akaPo6a0zpVCCseDed504KBJUQpEW5QZw7RMneNmKw+fGaML1Z9tUNLnHHAC8x6dzVRO1eB2oEMyZRnuBZg7Q== +zod-to-json-schema@^3.23.0: + version "3.23.0" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.23.0.tgz#4fc60e88d3c709eedbfaae3f92f8a7bf786469f2" + integrity sha512-az0uJ243PxsRIa2x1WmNE/pnuA05gUq/JB8Lwe1EDCCL/Fz9MgjYQ0fPlyc2Tcv6aF2ZA7WM5TWaRZVEFaAIag== + zod@3.22.4, zod@^3.22.3, zod@^3.22.4: version "3.22.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"