mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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:
parent
3a814a6746
commit
511012da92
2 changed files with 226 additions and 8 deletions
|
@ -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',
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Reference in a new issue