From 6ab25f56816527b65cf636ff034f58fec39db097 Mon Sep 17 00:00:00 2001 From: Charles Pick Date: Wed, 27 Dec 2023 12:41:42 +0000 Subject: [PATCH] Parse frontmatter in Zod descriptions --- package-lock.json | 91 +++++++++++-- packages/zod-openapi/package.json | 6 +- .../zod-openapi/src/lib/zod-openapi.spec.ts | 109 ++++++++++------ packages/zod-openapi/src/lib/zod-openapi.ts | 120 ++++++++++-------- 4 files changed, 223 insertions(+), 103 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48951de..515c4a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8987,7 +8987,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -9769,6 +9768,34 @@ "node": ">= 0.6" } }, + "node_modules/front-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", + "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", + "dependencies": { + "js-yaml": "^3.13.1" + } + }, + "node_modules/front-matter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/front-matter/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -15989,8 +16016,7 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "node_modules/stack-utils": { "version": "2.0.5", @@ -18007,15 +18033,30 @@ }, "packages/zod-openapi": { "name": "@anatine/zod-openapi", - "version": "2.2.1", + "version": "2.2.2", "license": "MIT", "dependencies": { + "dedent": "^1.5.1", + "front-matter": "^4.0.2", "ts-deepmerge": "^6.0.3" }, "peerDependencies": { "openapi3-ts": "^4.1.2", "zod": "^3.20.0" } + }, + "packages/zod-openapi/node_modules/dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } } }, "dependencies": { @@ -18204,7 +18245,16 @@ "@anatine/zod-openapi": { "version": "file:packages/zod-openapi", "requires": { + "dedent": "^1.5.1", + "front-matter": "^4.0.2", "ts-deepmerge": "^6.0.3" + }, + "dependencies": { + "dedent": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", + "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==" + } } }, "@angular-devkit/core": { @@ -24891,8 +24941,7 @@ "esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==" }, "esquery": { "version": "1.5.0", @@ -25501,6 +25550,33 @@ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", "dev": true }, + "front-matter": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/front-matter/-/front-matter-4.0.2.tgz", + "integrity": "sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==", + "requires": { + "js-yaml": "^3.13.1" + }, + "dependencies": { + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + } + } + }, "fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -30178,8 +30254,7 @@ "sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" }, "stack-utils": { "version": "2.0.5", diff --git a/packages/zod-openapi/package.json b/packages/zod-openapi/package.json index 7b6a015..898b323 100644 --- a/packages/zod-openapi/package.json +++ b/packages/zod-openapi/package.json @@ -21,10 +21,12 @@ "swagger" ], "dependencies": { + "dedent": "^1.5.1", + "front-matter": "^4.0.2", "ts-deepmerge": "^6.0.3" }, "peerDependencies": { - "zod": "^3.20.0", - "openapi3-ts": "^4.1.2" + "openapi3-ts": "^4.1.2", + "zod": "^3.20.0" } } diff --git a/packages/zod-openapi/src/lib/zod-openapi.spec.ts b/packages/zod-openapi/src/lib/zod-openapi.spec.ts index 24ebfa4..647eeeb 100644 --- a/packages/zod-openapi/src/lib/zod-openapi.spec.ts +++ b/packages/zod-openapi/src/lib/zod-openapi.spec.ts @@ -100,19 +100,23 @@ describe('zodOpenapi', () => { expect((apiSchema.properties?.aNever as SchemaObject).readOnly).toEqual( true ); - }) + }); it('should support branded types', () => { const zodSchema = extendApi( z.object({ - aBrandedString: z.string().describe('A branded test string').brand('BrandedString').optional(), + aBrandedString: z + .string() + .describe('A branded test string') + .brand('BrandedString') + .optional(), aBrandedNumber: z.number().brand('BrandedNumber').optional(), aBrandedBigInt: z.bigint().brand('BrandedBigInt'), aBrandedBoolean: z.boolean().brand('BrandedBoolean'), aBrandedDate: z.date().brand('BrandedDate'), }), { - description: `Branded primitives` + description: `Branded primitives`, } ); const apiSchema = generateSchema(zodSchema); @@ -120,7 +124,10 @@ describe('zodOpenapi', () => { expect(apiSchema).toEqual({ type: 'object', properties: { - aBrandedString: { description: 'A branded test string', type: 'string' }, + aBrandedString: { + description: 'A branded test string', + type: 'string', + }, aBrandedNumber: { type: 'number' }, aBrandedBigInt: { type: 'integer', format: 'int64' }, aBrandedBoolean: { type: 'boolean' }, @@ -284,7 +291,7 @@ describe('zodOpenapi', () => { items: { type: 'number' }, }, }, - "hideDefinitions": ["aArrayLength"], + hideDefinitions: ['aArrayLength'], description: 'I need arrays', }); }); @@ -293,12 +300,12 @@ describe('zodOpenapi', () => { const zodNestedSchema = extendApi( z.strictObject({ aString: z.string(), - aNumber: z.number().optional() + aNumber: z.number().optional(), }), { - hideDefinitions: ['aNumber'] + hideDefinitions: ['aNumber'], } - ) + ); const zodSchema = extendApi( z.object({ data: zodNestedSchema }).partial(), { @@ -307,25 +314,22 @@ describe('zodOpenapi', () => { ); const apiSchema = generateSchema(zodSchema); expect(apiSchema).toEqual({ - "description": "I need arrays", - "properties": { - "data": { - "additionalProperties": false, - "properties": { - "aString": { - "type": "string", + description: 'I need arrays', + properties: { + data: { + additionalProperties: false, + properties: { + aString: { + type: 'string', }, }, - "hideDefinitions": ["aNumber"], - "required": [ - "aString", - ], - "type": "object", + hideDefinitions: ['aNumber'], + required: ['aString'], + type: 'object', }, }, - "type": "object", - } - ); + type: 'object', + }); }); }); @@ -501,7 +505,7 @@ describe('zodOpenapi', () => { }) .strict(), { - description: "Super strict", + description: 'Super strict', } ); const apiSchema = generateSchema(zodSchema); @@ -513,7 +517,7 @@ describe('zodOpenapi', () => { aNumber: { type: 'number' }, }, additionalProperties: false, - description: "Super strict", + description: 'Super strict', }); }); @@ -904,33 +908,35 @@ describe('zodOpenapi', () => { it('can summarize unions of zod literals as an enum', () => { expect(generateSchema(z.union([z.literal('h'), z.literal('i')]))).toEqual({ type: 'string', - enum: ['h', 'i'] + enum: ['h', 'i'], }); expect(generateSchema(z.union([z.literal(3), z.literal(4)]))).toEqual({ type: 'number', - enum: [3, 4] + enum: [3, 4], }); // should this just remove the enum? true | false is exhaustive... - expect(generateSchema(z.union([z.literal(true), z.literal(false)]))).toEqual({ + expect( + generateSchema(z.union([z.literal(true), z.literal(false)])) + ).toEqual({ type: 'boolean', - enum: [true, false] + enum: [true, false], }); expect(generateSchema(z.union([z.literal(5), z.literal('i')]))).toEqual({ oneOf: [ { type: 'number', - enum: [5] + enum: [5], }, { type: 'string', - enum: ['i'] - } - ] + enum: ['i'], + }, + ], }); - }) + }); it('should work with ZodPipeline', () => { expect( @@ -962,7 +968,6 @@ describe('zodOpenapi', () => { } satisfies SchemaObject); }); - it('should work with ZodTransform and correctly set nullable and optional', () => { type Type = string; const schema = z.object({ @@ -994,9 +999,8 @@ describe('zodOpenapi', () => { required: ['item'], type: 'object', }); - }); - + test('should work with ZodReadonly', () => { expect(generateSchema(z.object({ field: z.string() }))) .toMatchInlineSnapshot(` @@ -1027,6 +1031,37 @@ describe('zodOpenapi', () => { "type": "object", } `); + }); + test('should parse front matter', () => { + expect( + generateSchema( + z.object({ + field: z.string().describe(` + --- + title: "Test" + deprecated: because reasons + --- + This is a description + `), + }) + ) + ).toMatchInlineSnapshot(` + Object { + "properties": Object { + "field": Object { + "deprecated": true, + "description": "This is a description", + "type": "string", + "x-deprecated": "because reasons", + "x-title": "Test", + }, + }, + "required": Array [ + "field", + ], + "type": "object", + } + `); }); }); diff --git a/packages/zod-openapi/src/lib/zod-openapi.ts b/packages/zod-openapi/src/lib/zod-openapi.ts index 16231af..728130d 100644 --- a/packages/zod-openapi/src/lib/zod-openapi.ts +++ b/packages/zod-openapi/src/lib/zod-openapi.ts @@ -1,6 +1,8 @@ import type { SchemaObject, SchemaObjectType } from 'openapi3-ts/oas31'; import merge from 'ts-deepmerge'; import { AnyZodObject, z, ZodTypeAny } from 'zod'; +import * as frontMatter from 'front-matter'; +import dedent from 'dedent'; type AnatineSchemaObject = SchemaObject & { hideDefinitions?: string[] }; @@ -45,6 +47,27 @@ function iterateZodObject({ return reduced; } +function parseDescription(zodRef: OpenApiZodAny): SchemaObject { + if (!zodRef.description) return {}; + const trimmedDescription = dedent(zodRef.description); + // @ts-expect-error front-matter types are incorrect, see https://github.com/jxson/front-matter/pull/77 + if (!frontMatter.test(trimmedDescription)) + return { description: zodRef.description }; + // @ts-expect-error front-matter types are incorrect, see https://github.com/jxson/front-matter/pull/77 + const { attributes, body } = frontMatter(trimmedDescription); + const output: SchemaObject = {}; + if (body.trim()) output.description = body.trim(); + if (typeof attributes === 'object' && attributes !== null) { + if ('deprecated' in attributes && attributes.deprecated) { + output.deprecated = true; + } + for (const [key, value] of Object.entries(attributes)) { + output[`x-${key}`] = value; + } + } + return output; +} + function parseTransformation({ zodRef, schemas, @@ -62,16 +85,16 @@ function parseTransformation({ ['integer', 'number'].includes(`${input.type}`) ? 0 : 'string' === input.type - ? '' - : 'boolean' === input.type - ? false - : 'object' === input.type - ? {} - : 'null' === input.type - ? null - : 'array' === input.type - ? [] - : undefined, + ? '' + : 'boolean' === input.type + ? false + : 'object' === input.type + ? {} + : 'null' === input.type + ? null + : 'array' === input.type + ? [] + : undefined, { addIssue: () => undefined, path: [] } // TODO: Discover if context is necessary here ); } catch (e) { @@ -85,8 +108,8 @@ function parseTransformation({ ...input, ...(['number', 'string', 'boolean', 'null'].includes(output) ? { - type: output as 'number' | 'string' | 'boolean' | 'null', - } + type: output as 'number' | 'string' | 'boolean' | 'null', + } : {}), }, ...schemas @@ -133,11 +156,7 @@ function parseString({ break; } }); - return merge( - baseSchema, - zodRef.description ? { description: zodRef.description } : {}, - ...schemas - ); + return merge(baseSchema, parseDescription(zodRef), ...schemas); } function parseNumber({ @@ -166,26 +185,20 @@ function parseNumber({ baseSchema.multipleOf = item.value; } }); - return merge( - baseSchema, - zodRef.description ? { description: zodRef.description } : {}, - ...schemas - ); + return merge(baseSchema, parseDescription(zodRef), ...schemas); } - - -function getExcludedDefinitionsFromSchema(schemas: AnatineSchemaObject[]): string[] { - - +function getExcludedDefinitionsFromSchema( + schemas: AnatineSchemaObject[] +): string[] { const excludedDefinitions = []; for (const schema of schemas) { if (Array.isArray(schema.hideDefinitions)) { - excludedDefinitions.push(...schema.hideDefinitions) + excludedDefinitions.push(...schema.hideDefinitions); } } - return excludedDefinitions + return excludedDefinitions; } function parseObject({ @@ -241,9 +254,11 @@ function parseObject({ }), ...required, ...additionalProperties, - ...hideDefinitions + ...hideDefinitions, }, - zodRef.description ? { description: zodRef.description, hideDefinitions } : {}, + zodRef.description + ? { description: zodRef.description, hideDefinitions } + : {}, ...schemas ); } @@ -261,7 +276,7 @@ function parseRecord({ ? {} : generateSchema(zodRef._def.valueType, useOutput), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -272,7 +287,7 @@ function parseBigInt({ }: ParsingArgs): SchemaObject { return merge( { type: 'integer' as SchemaObjectType, format: 'int64' }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -283,7 +298,7 @@ function parseBoolean({ }: ParsingArgs): SchemaObject { return merge( { type: 'boolean' as SchemaObjectType }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -291,7 +306,7 @@ function parseBoolean({ function parseDate({ zodRef, schemas }: ParsingArgs): SchemaObject { return merge( { type: 'string' as SchemaObjectType, format: 'date-time' }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -301,7 +316,7 @@ function parseNull({ zodRef, schemas }: ParsingArgs): SchemaObject { { type: 'null' as SchemaObjectType, }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -313,7 +328,7 @@ function parseOptional({ }: ParsingArgs>): SchemaObject { return merge( generateSchema(zodRef.unwrap(), useOutput), - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -326,7 +341,7 @@ function parseNullable({ const schema = generateSchema(zodRef.unwrap(), useOutput); return merge( { ...schema, type: [schema.type, 'null'] as SchemaObjectType[] }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -341,7 +356,7 @@ function parseDefault({ default: zodRef._def.defaultValue(), ...generateSchema(zodRef._def.innerType, useOutput), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -368,7 +383,7 @@ function parseArray({ items: generateSchema(zodRef.element, useOutput), ...constraints, }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -382,7 +397,7 @@ function parseLiteral({ type: typeof zodRef._def.value as 'string' | 'number' | 'boolean', enum: [zodRef._def.value], }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -396,7 +411,7 @@ function parseEnum({ type: typeof Object.values(zodRef._def.values)[0] as 'string' | 'number', enum: Object.values(zodRef._def.values), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -413,7 +428,7 @@ function parseIntersection({ generateSchema(zodRef._def.right, useOutput), ], }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -446,7 +461,7 @@ function parseUnion({ type: type as 'string' | 'number' | 'boolean', enum: literals.map((literal) => literal._def.value), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -456,7 +471,7 @@ function parseUnion({ { oneOf: contents.map((schema) => generateSchema(schema, useOutput)), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -487,7 +502,7 @@ function parseDiscriminatedUnion({ )._def.options.values() ).map((schema) => generateSchema(schema, useOutput)), }, - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); } @@ -496,11 +511,7 @@ function parseNever({ zodRef, schemas, }: ParsingArgs): SchemaObject { - return merge( - { readOnly: true }, - zodRef.description ? { description: zodRef.description } : {}, - ...schemas - ); + return merge({ readOnly: true }, parseDescription(zodRef), ...schemas); } function parseBranded({ @@ -514,10 +525,7 @@ function catchAllParser({ zodRef, schemas, }: ParsingArgs): SchemaObject { - return merge( - zodRef.description ? { description: zodRef.description } : {}, - ...schemas - ); + return merge(parseDescription(zodRef), ...schemas); } function parsePipeline({ @@ -537,7 +545,7 @@ function parseReadonly({ }: ParsingArgs>): SchemaObject { return merge( generateSchema(zodRef._def.innerType, useOutput), - zodRef.description ? { description: zodRef.description } : {}, + parseDescription(zodRef), ...schemas ); }