diff --git a/packages/core/src/utils/zod.test.ts b/packages/core/src/utils/zod.test.ts index 29ff302f0..a9104d9bd 100644 --- a/packages/core/src/utils/zod.test.ts +++ b/packages/core/src/utils/zod.test.ts @@ -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', diff --git a/packages/core/src/utils/zod.ts b/packages/core/src/utils/zod.ts index 3900c26fb..f3a4b7ab6 100644 --- a/packages/core/src/utils/zod.ts +++ b/packages/core/src/utils/zod.ts @@ -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; + +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): 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) {