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:
parent
3a814a6746
commit
511012da92
2 changed files with 226 additions and 8 deletions
|
@ -1,9 +1,9 @@
|
||||||
import { ApplicationType, arbitraryObjectGuard } from '@logto/schemas';
|
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 RequestError from '@/errors/RequestError';
|
||||||
|
|
||||||
import { zodTypeToSwagger } from './zod';
|
import { ZodStringCheck, zodTypeToSwagger } from './zod';
|
||||||
|
|
||||||
describe('zodTypeToSwagger', () => {
|
describe('zodTypeToSwagger', () => {
|
||||||
it('arbitrary object guard', () => {
|
it('arbitrary object guard', () => {
|
||||||
|
@ -13,8 +13,75 @@ describe('zodTypeToSwagger', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('string type', () => {
|
describe('string type', () => {
|
||||||
expect(zodTypeToSwagger(string())).toEqual({ type: 'string' });
|
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', () => {
|
it('boolean type', () => {
|
||||||
|
@ -57,10 +124,75 @@ describe('zodTypeToSwagger', () => {
|
||||||
expect(zodTypeToSwagger(string().nullable())).toEqual({ type: 'string', nullable: true });
|
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', () => {
|
it('unknown type', () => {
|
||||||
expect(zodTypeToSwagger(unknown())).toEqual({ example: {} });
|
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', () => {
|
it('native enum type', () => {
|
||||||
expect(zodTypeToSwagger(nativeEnum(ApplicationType))).toEqual({
|
expect(zodTypeToSwagger(nativeEnum(ApplicationType))).toEqual({
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|
|
@ -1,20 +1,98 @@
|
||||||
import { arbitraryObjectGuard } from '@logto/schemas';
|
import { arbitraryObjectGuard } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional, ValuesOf } from '@silverhand/essentials';
|
||||||
import { OpenAPIV3 } from 'openapi-types';
|
import { OpenAPIV3 } from 'openapi-types';
|
||||||
import {
|
import {
|
||||||
ZodArray,
|
ZodArray,
|
||||||
ZodBoolean,
|
ZodBoolean,
|
||||||
|
ZodLiteral,
|
||||||
ZodNativeEnum,
|
ZodNativeEnum,
|
||||||
ZodNullable,
|
ZodNullable,
|
||||||
ZodNumber,
|
ZodNumber,
|
||||||
ZodObject,
|
ZodObject,
|
||||||
ZodOptional,
|
ZodOptional,
|
||||||
ZodString,
|
ZodString,
|
||||||
|
ZodStringDef,
|
||||||
|
ZodType,
|
||||||
|
ZodUnion,
|
||||||
ZodUnknown,
|
ZodUnknown,
|
||||||
} from 'zod';
|
} from 'zod';
|
||||||
|
|
||||||
import RequestError from '@/errors/RequestError';
|
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 => {
|
export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
||||||
if (config === arbitraryObjectGuard) {
|
if (config === arbitraryObjectGuard) {
|
||||||
return {
|
return {
|
||||||
|
@ -41,10 +119,20 @@ export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config instanceof ZodLiteral) {
|
||||||
|
return zodLiteralToSwagger(config);
|
||||||
|
}
|
||||||
|
|
||||||
if (config instanceof ZodUnknown) {
|
if (config instanceof ZodUnknown) {
|
||||||
return { example: {} }; // Any data type
|
return { example: {} }; // Any data type
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (config instanceof ZodUnion) {
|
||||||
|
return {
|
||||||
|
oneOf: (config.options as ZodType[]).map((option) => zodTypeToSwagger(option)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (config instanceof ZodObject) {
|
if (config instanceof ZodObject) {
|
||||||
const entries = Object.entries(config.shape);
|
const entries = Object.entries(config.shape);
|
||||||
const required = entries
|
const required = entries
|
||||||
|
@ -66,9 +154,7 @@ export const zodTypeToSwagger = (config: unknown): OpenAPIV3.SchemaObject => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config instanceof ZodString) {
|
if (config instanceof ZodString) {
|
||||||
return {
|
return zodStringToSwagger(config);
|
||||||
type: 'string',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config instanceof ZodNumber) {
|
if (config instanceof ZodNumber) {
|
||||||
|
|
Loading…
Reference in a new issue