0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-23 20:33:16 -05:00

feat(core): convert Zod union, literal and string guards to OpenAPI schemas (#1126)

* feat(core): convert Zod union and literal guards to OpenAPI schemas

* feat(core): parse swagger schema format with Zod string checks

* test(core): unexpected zod string check

* test(core): unexpected zod literal check
This commit is contained in:
IceHe.xyz 2022-06-16 16:46:38 +08:00 committed by GitHub
parent 3a814a6746
commit 511012da92
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 226 additions and 8 deletions

View file

@ -1,9 +1,9 @@
import { ApplicationType, arbitraryObjectGuard } from '@logto/schemas';
import { string, boolean, number, object, nativeEnum, unknown } from 'zod';
import { string, boolean, number, object, nativeEnum, unknown, literal, union } from 'zod';
import RequestError from '@/errors/RequestError';
import { zodTypeToSwagger } from './zod';
import { ZodStringCheck, zodTypeToSwagger } from './zod';
describe('zodTypeToSwagger', () => {
it('arbitrary object guard', () => {
@ -13,8 +13,75 @@ describe('zodTypeToSwagger', () => {
});
});
it('string type', () => {
expect(zodTypeToSwagger(string())).toEqual({ type: 'string' });
describe('string type', () => {
const notStartingWithDigitRegex = /^\D/;
it('nonempty check', () => {
expect(zodTypeToSwagger(string().nonempty())).toEqual({
type: 'string',
minLength: 1,
});
});
it('min check', () => {
expect(zodTypeToSwagger(string().min(1))).toEqual({
type: 'string',
minLength: 1,
});
});
it('max check', () => {
expect(zodTypeToSwagger(string().max(6))).toEqual({
type: 'string',
maxLength: 6,
});
});
it('regex check', () => {
expect(zodTypeToSwagger(string().regex(notStartingWithDigitRegex))).toEqual({
type: 'string',
format: 'regex',
pattern: notStartingWithDigitRegex.toString(),
});
});
it('other kinds check', () => {
expect(zodTypeToSwagger(string().email())).toEqual({
type: 'string',
format: 'email',
});
expect(zodTypeToSwagger(string().url())).toEqual({
type: 'string',
format: 'url',
});
expect(zodTypeToSwagger(string().uuid())).toEqual({
type: 'string',
format: 'uuid',
});
expect(zodTypeToSwagger(string().cuid())).toEqual({
type: 'string',
format: 'cuid',
});
});
it('combination check', () => {
expect(
zodTypeToSwagger(string().min(1).max(128).email().uuid().regex(notStartingWithDigitRegex))
).toEqual({
type: 'string',
format: 'email | uuid | regex',
minLength: 1,
maxLength: 128,
pattern: notStartingWithDigitRegex.toString(),
});
});
it('unexpected check', () => {
const unexpectedCheck = { kind: 'unexpected' };
expect(() =>
zodTypeToSwagger(string()._addCheck(unexpectedCheck as ZodStringCheck))
).toMatchError(new RequestError('swagger.invalid_zod_type', unexpectedCheck));
});
});
it('boolean type', () => {
@ -57,10 +124,75 @@ describe('zodTypeToSwagger', () => {
expect(zodTypeToSwagger(string().nullable())).toEqual({ type: 'string', nullable: true });
});
describe('literal type', () => {
it('boolean', () => {
expect(zodTypeToSwagger(literal(true))).toEqual({
type: 'boolean',
format: 'true',
});
expect(zodTypeToSwagger(literal(false))).toEqual({
type: 'boolean',
format: 'false',
});
});
it('number', () => {
expect(zodTypeToSwagger(literal(-1.25))).toEqual({
type: 'number',
format: '-1.25',
});
expect(zodTypeToSwagger(literal(999))).toEqual({
type: 'number',
format: '999',
});
});
it('string', () => {
expect(zodTypeToSwagger(literal(''))).toEqual({
type: 'string',
format: 'empty',
});
expect(zodTypeToSwagger(literal('nonempty'))).toEqual({
type: 'string',
format: '"nonempty"',
});
});
it('unexpected', () => {
const bigIntLiteral = literal(BigInt(1_000_000_000));
expect(() => zodTypeToSwagger(bigIntLiteral)).toMatchError(
new RequestError('swagger.invalid_zod_type', bigIntLiteral)
);
// eslint-disable-next-line unicorn/no-useless-undefined
const undefinedLiteral = literal(undefined);
expect(() => zodTypeToSwagger(undefinedLiteral)).toMatchError(
new RequestError('swagger.invalid_zod_type', undefinedLiteral)
);
const nullLiteral = literal(null);
expect(() => zodTypeToSwagger(nullLiteral)).toMatchError(
new RequestError('swagger.invalid_zod_type', nullLiteral)
);
});
});
it('unknown type', () => {
expect(zodTypeToSwagger(unknown())).toEqual({ example: {} });
});
it('union type', () => {
expect(zodTypeToSwagger(number().or(boolean()))).toEqual({
oneOf: [{ type: 'number' }, { type: 'boolean' }],
});
expect(zodTypeToSwagger(union([literal('Logto'), literal(true)]))).toEqual({
oneOf: [
{ type: 'string', format: '"Logto"' },
{ type: 'boolean', format: 'true' },
],
});
});
it('native enum type', () => {
expect(zodTypeToSwagger(nativeEnum(ApplicationType))).toEqual({
type: 'string',

View file

@ -1,20 +1,98 @@
import { arbitraryObjectGuard } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { conditional, ValuesOf } from '@silverhand/essentials';
import { OpenAPIV3 } from 'openapi-types';
import {
ZodArray,
ZodBoolean,
ZodLiteral,
ZodNativeEnum,
ZodNullable,
ZodNumber,
ZodObject,
ZodOptional,
ZodString,
ZodStringDef,
ZodType,
ZodUnion,
ZodUnknown,
} from 'zod';
import RequestError from '@/errors/RequestError';
export type ZodStringCheck = ValuesOf<ZodStringDef['checks']>;
const zodStringCheckToSwaggerFormat = (zodStringCheck: ZodStringCheck) => {
const { kind } = zodStringCheck;
switch (kind) {
case 'email':
case 'url':
case 'uuid':
case 'cuid':
case 'regex':
return kind;
case 'min':
case 'max':
// Do nothing here
return;
default:
throw new RequestError('swagger.invalid_zod_type', zodStringCheck);
}
};
// https://github.com/colinhacks/zod#strings
const zodStringToSwagger = (zodString: ZodString): OpenAPIV3.SchemaObject => {
const { checks } = zodString._def;
const formats = checks
.map((zodStringCheck) => zodStringCheckToSwaggerFormat(zodStringCheck))
.filter((format) => format);
const minLength = checks.find(
(check): check is { kind: 'min'; value: number } => check.kind === 'min'
)?.value;
const maxLength = checks.find(
(check): check is { kind: 'max'; value: number } => check.kind === 'max'
)?.value;
const pattern = checks
.find((check): check is { kind: 'regex'; regex: RegExp } => check.kind === 'regex')
?.regex.toString();
return {
type: 'string',
format: formats.length > 0 ? formats.join(' | ') : undefined,
minLength,
maxLength,
pattern,
};
};
// https://github.com/colinhacks/zod#literals
const zodLiteralToSwagger = (zodLiteral: ZodLiteral<unknown>): OpenAPIV3.SchemaObject => {
const { value } = zodLiteral;
switch (typeof value) {
case 'boolean':
return {
type: 'boolean',
format: String(value),
};
case 'number':
return {
type: 'number',
format: String(value),
};
case 'string':
return {
type: 'string',
format: value === '' ? 'empty' : `"${value}"`,
};
default:
throw new RequestError('swagger.invalid_zod_type', zodLiteral);
}
};
export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
if (config === arbitraryObjectGuard) {
return {
@ -41,10 +119,20 @@ export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
};
}
if (config instanceof ZodLiteral) {
return zodLiteralToSwagger(config);
}
if (config instanceof ZodUnknown) {
return { example: {} }; // Any data type
}
if (config instanceof ZodUnion) {
return {
oneOf: (config.options as ZodType[]).map((option) => zodTypeToSwagger(option)),
};
}
if (config instanceof ZodObject) {
const entries = Object.entries(config.shape);
const required = entries
@ -66,9 +154,7 @@ export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
}
if (config instanceof ZodString) {
return {
type: 'string',
};
return zodStringToSwagger(config);
}
if (config instanceof ZodNumber) {