Skip to content

Commit

Permalink
add support for zod in oas converter (pass #1)
Browse files Browse the repository at this point in the history
  • Loading branch information
jloleysens committed Jun 13, 2024
1 parent ed70d4c commit 51bd6b0
Show file tree
Hide file tree
Showing 4 changed files with 238 additions and 2 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, OpenAPIV3.SchemaObject>();

#getConverter(schema: unknown) {
Expand Down
226 changes: 226 additions & 0 deletions packages/kbn-router-to-openapispec/src/oas_converter/zod.ts
Original file line number Diff line number Diff line change
@@ -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 = <Z extends z.ZodFirstPartyTypeKind>(
type: z.ZodTypeAny,
zodTypeKind: Z
): type is InstanceType<typeof z[Z]> => {
return type?._def?.typeName === zodTypeKind;
};

const instanceofZodTypeObject = (type: z.ZodTypeAny): type is z.ZodObject<z.ZodRawShape> => {
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<ZodTypeLikeString>
| z.ZodDefault<ZodTypeLikeString>
| z.ZodEffects<ZodTypeLikeString, unknown, unknown>
| z.ZodUnion<[ZodTypeLikeString, ...ZodTypeLikeString[]]>
| z.ZodIntersection<ZodTypeLikeString, ZodTypeLikeString>
| z.ZodLazy<ZodTypeLikeString>
| z.ZodLiteral<string>
| z.ZodEnum<[string, ...string[]]>
| z.ZodNativeEnum<NativeEnumType>;

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,
};
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down

0 comments on commit 51bd6b0

Please sign in to comment.