From a69b8e5a375305f84781a808dddd0129119b08a1 Mon Sep 17 00:00:00 2001 From: Young Jun Joo Date: Fri, 14 Mar 2025 11:58:17 -0400 Subject: [PATCH] feat: enhance OpenAPI spec generation with schema.type fix and query parameter support Corrected a typo in knownImports.ts and enhanced query parameter generation logic ensuring compliance with OpenAPI 3.0.3 specifications. These changes produce a more accurate and complete OpenAPI spec, with query parameters now properly documented alongside schema types, improving API usability and clarity for downstream consumers. --- .../openapi-generator/src/knownImports.ts | 2 +- packages/openapi-generator/src/openapi.ts | 192 ++++++- packages/openapi-generator/src/route.ts | 2 - .../test/openapi/base.test.ts | 2 - .../test/openapi/jsdoc.test.ts | 2 - .../test/openapi/queryParams.test.ts | 485 ++++++++++++++++++ .../test/openapi/union.test.ts | 4 +- packages/openapi-generator/test/route.test.ts | 2 - 8 files changed, 655 insertions(+), 36 deletions(-) create mode 100644 packages/openapi-generator/test/openapi/queryParams.test.ts diff --git a/packages/openapi-generator/src/knownImports.ts b/packages/openapi-generator/src/knownImports.ts index fab630d0..88f07595 100644 --- a/packages/openapi-generator/src/knownImports.ts +++ b/packages/openapi-generator/src/knownImports.ts @@ -297,7 +297,7 @@ export const KNOWN_IMPORTS: KnownImports = { E.right({ type: 'string', format: 'number', - maximum: 0, + minimum: 0, decodedType: 'bigint', }), NonPositiveBigInt: () => diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index a7fe83de..370a962b 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -9,11 +9,14 @@ import { Block } from 'comment-parser'; export function schemaToOpenAPI( schema: Schema, + parent?: Record, + paramName?: string, ): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined { schema = optimize(schema); const createOpenAPIObject = ( schema: Schema, + paramName?: string, ): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined => { const defaultOpenAPIObject = buildDefaultOpenAPIObject(schema); @@ -28,7 +31,7 @@ export function schemaToOpenAPI( }; case 'integer': return { - type: 'number', + type: 'integer', ...(schema.enum ? { enum: schema.enum } : {}), ...defaultOpenAPIObject, }; @@ -46,7 +49,7 @@ export function schemaToOpenAPI( ...defaultOpenAPIObject, }; case 'array': - const innerSchema = schemaToOpenAPI(schema.items); + const innerSchema = schemaToOpenAPI(schema.items, parent, paramName); if (innerSchema === undefined) { return undefined; } @@ -71,7 +74,7 @@ export function schemaToOpenAPI( ...defaultOpenAPIObject, properties: Object.entries(schema.properties).reduce( (acc, [name, prop]) => { - const innerSchema = schemaToOpenAPI(prop); + const innerSchema = schemaToOpenAPI(prop, parent, paramName); if (innerSchema === undefined) { return acc; } @@ -85,7 +88,7 @@ export function schemaToOpenAPI( case 'intersection': return { allOf: schema.schemas.flatMap((s) => { - const innerSchema = schemaToOpenAPI(s); + const innerSchema = schemaToOpenAPI(s, parent, paramName); if (innerSchema === undefined) { return []; } @@ -110,11 +113,15 @@ export function schemaToOpenAPI( const isOptional = schema.schemas.length >= 2 && undefinedSchema && nonUndefinedSchema; if (isOptional) { - return schemaToOpenAPI({ - ...nonUndefinedSchema, - comment: schema.comment, - ...(nullSchema ? { nullable: true } : {}), - }); + return schemaToOpenAPI( + { + ...nonUndefinedSchema, + comment: schema.comment, + ...(nullSchema ? { nullable: true } : {}), + }, + parent, + paramName, + ); } // This is an edge case for something like this -> t.union([WellDefinedCodec, t.unknown]) @@ -125,18 +132,26 @@ export function schemaToOpenAPI( const nonUnknownSchemas = schema.schemas.filter((s) => s.type !== 'any'); if (nonUnknownSchemas.length === 1 && nonUnknownSchemas[0] !== undefined) { - return schemaToOpenAPI({ - ...nonUnknownSchemas[0], - comment: schema.comment, - ...(nullSchema ? { nullable: true } : {}), - }); + return schemaToOpenAPI( + { + ...nonUnknownSchemas[0], + comment: schema.comment, + ...(nullSchema ? { nullable: true } : {}), + }, + parent, + paramName, + ); } else if (nonUnknownSchemas.length > 1) { - return schemaToOpenAPI({ - type: 'union', - schemas: nonUnknownSchemas, - comment: schema.comment, - ...(nullSchema ? { nullable: true } : {}), - }); + return schemaToOpenAPI( + { + type: 'union', + schemas: nonUnknownSchemas, + comment: schema.comment, + ...(nullSchema ? { nullable: true } : {}), + }, + parent, + paramName, + ); } } @@ -145,7 +160,7 @@ export function schemaToOpenAPI( nullable = true; continue; } - const innerSchema = schemaToOpenAPI(s); + const innerSchema = schemaToOpenAPI(s, parent, paramName); if (innerSchema !== undefined) { oneOf.push(innerSchema); } @@ -174,7 +189,11 @@ export function schemaToOpenAPI( return { ...(nullable ? { nullable } : {}), oneOf, ...defaultOpenAPIObject }; } case 'record': - const additionalProperties = schemaToOpenAPI(schema.codomain); + const additionalProperties = schemaToOpenAPI( + schema.codomain, + parent, + paramName, + ); if (additionalProperties === undefined) return undefined; if (schema.domain !== undefined) { @@ -201,6 +220,13 @@ export function schemaToOpenAPI( case 'undefined': return undefined; case 'any': + if (schema.title === 'JSON') { + return { + type: 'string', + format: 'json', + additionalProperties: true, + }; + } return {}; default: return {}; @@ -287,7 +313,124 @@ export function schemaToOpenAPI( return defaultOpenAPIObject; } - let openAPIObject = createOpenAPIObject(schema); + let openAPIObject = createOpenAPIObject(schema, paramName); + + if (schema.type === 'string' && openAPIObject) { + const constraintProps: Record = {}; + if (schema.maximum !== undefined) constraintProps['maximum'] = schema.maximum; + if (schema.exclusiveMaximum !== undefined) + constraintProps['exclusiveMaximum'] = schema.exclusiveMaximum; + if (schema.minimum !== undefined) constraintProps['minimum'] = schema.minimum; + if (schema.exclusiveMinimum !== undefined) + constraintProps['exclusiveMinimum'] = schema.exclusiveMinimum; + if (schema.multipleOf !== undefined) + constraintProps['multipleOf'] = schema.multipleOf; + + if (schema.format === 'integer' && schema.decodedType === 'number') { + if ('type' in openAPIObject) { + const { format, ...rest } = openAPIObject; + openAPIObject = { + ...rest, + type: 'integer', + ...constraintProps, + }; + } + } else if (schema.format === 'number' && schema.decodedType === 'number') { + if ('type' in openAPIObject) { + const { format, ...rest } = openAPIObject; + + let useIntegerType = false; + const intCodecPattern = + /(Negative|NonNegative|NonPositive|NonZero)IntFromString$/i; + + if (paramName && typeof paramName === 'string') { + useIntegerType = intCodecPattern.test(paramName); + } + + openAPIObject = { + ...rest, + type: useIntegerType ? 'integer' : 'number', + ...constraintProps, + }; + } + } else if (schema.decodedType === 'bigint') { + if ('type' in openAPIObject) { + const { format, ...rest } = openAPIObject; + + const bigIntConstraints: Record = {}; + + if (schema.maximum !== undefined) { + bigIntConstraints.maximum = schema.maximum; + } + if (schema.exclusiveMaximum !== undefined) { + bigIntConstraints.exclusiveMaximum = schema.exclusiveMaximum; + } + if (schema.minimum !== undefined) { + bigIntConstraints.minimum = schema.minimum; + } + if (schema.exclusiveMinimum !== undefined) { + bigIntConstraints.exclusiveMinimum = schema.exclusiveMinimum; + } + if (schema.multipleOf !== undefined) { + bigIntConstraints.multipleOf = schema.multipleOf; + } + + const newObj = { + ...rest, + type: 'integer', + format: 'int64', + ...bigIntConstraints, + }; + + if (schema.maximum !== undefined && schema.minimum === undefined) { + delete newObj.minimum; + } + if (schema.minimum !== undefined && schema.maximum === undefined) { + delete newObj.maximum; + } + + openAPIObject = newObj as OpenAPIV3.SchemaObject; + } + } else if (schema.format === 'date-time') { + if ('type' in openAPIObject) { + const { title, ...rest } = openAPIObject; + openAPIObject = { + ...rest, + type: 'string', + format: 'date-time', + } as OpenAPIV3.SchemaObject; + } + } else if (schema.format === 'date') { + if ('type' in openAPIObject) { + const { title, ...rest } = openAPIObject; + openAPIObject = { + ...rest, + type: 'string', + format: 'date', + } as OpenAPIV3.SchemaObject; + } + } else if (schema.title === 'JSON String') { + if ('type' in openAPIObject) { + const { title, ...rest } = openAPIObject; + openAPIObject = { + ...rest, + type: 'string', + format: 'json', + } as OpenAPIV3.SchemaObject; + } + } + } + + if (openAPIObject && 'type' in openAPIObject && 'title' in openAPIObject) { + if (openAPIObject.type === 'string' && openAPIObject.title === 'uuid') { + const { title, ...rest } = openAPIObject; + openAPIObject = { + ...rest, + type: 'string', + format: 'uuid', + } as OpenAPIV3.SchemaObject; + } + } return openAPIObject; } @@ -346,7 +489,7 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec : {}), parameters: route.parameters.map((p) => { // Array types not allowed here - const schema = schemaToOpenAPI(p.schema); + const schema = schemaToOpenAPI(p.schema, undefined, p.name); if (schema && 'description' in schema) { delete schema.description; @@ -365,7 +508,6 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec in: p.type, ...(isPrivate ? { 'x-internal': true } : {}), ...(p.required ? { required: true } : {}), - ...(p.explode ? { style: 'form', explode: true } : {}), schema: schema as any, // TODO: Something to disallow arrays }; }), diff --git a/packages/openapi-generator/src/route.ts b/packages/openapi-generator/src/route.ts index fa7c84bb..95cb5c93 100644 --- a/packages/openapi-generator/src/route.ts +++ b/packages/openapi-generator/src/route.ts @@ -11,7 +11,6 @@ export type Parameter = { type: 'path' | 'query' | 'header'; name: string; schema: Schema; - explode?: boolean; required: boolean; }; @@ -151,7 +150,6 @@ function parseRequestUnion( parameters.push({ type: 'query', name: 'union', - explode: true, required: true, schema: querySchema, }); diff --git a/packages/openapi-generator/test/openapi/base.test.ts b/packages/openapi-generator/test/openapi/base.test.ts index db7409c1..c08f0bb2 100644 --- a/packages/openapi-generator/test/openapi/base.test.ts +++ b/packages/openapi-generator/test/openapi/base.test.ts @@ -287,8 +287,6 @@ testCase('request union route', UNION, { in: 'query', name: 'union', required: true, - style: 'form', - explode: true, schema: { oneOf: [ { diff --git a/packages/openapi-generator/test/openapi/jsdoc.test.ts b/packages/openapi-generator/test/openapi/jsdoc.test.ts index ccdd5e34..21cb44ab 100644 --- a/packages/openapi-generator/test/openapi/jsdoc.test.ts +++ b/packages/openapi-generator/test/openapi/jsdoc.test.ts @@ -60,8 +60,6 @@ testCase('schema parameter with title tag', TITLE_TAG, { in: 'query', name: 'union', required: true, - style: 'form', - explode: true, schema: { oneOf: [ { diff --git a/packages/openapi-generator/test/openapi/queryParams.test.ts b/packages/openapi-generator/test/openapi/queryParams.test.ts new file mode 100644 index 00000000..db0bd087 --- /dev/null +++ b/packages/openapi-generator/test/openapi/queryParams.test.ts @@ -0,0 +1,485 @@ +import { testCase } from './testHarness'; + +const BASIC_CODEC_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; +import { NumberFromString, IntFromString, BooleanFromString } from 'io-ts-types'; +import { NaturalFromString, NegativeFromString, NegativeIntFromString, NonNegativeFromString, NonPositiveFromString, NonPositiveIntFromString, NonZeroFromString, NonZeroIntFromString, PositiveFromString, ZeroFromString } from 'io-ts-numbers'; +import { BigIntFromString, NegativeBigIntFromString, NonNegativeBigIntFromString, NonPositiveBigIntFromString, NonZeroBigIntFromString, PositiveBigIntFromString, ZeroBigIntFromString } from 'io-ts-bigint'; +import { DateFromISOString, JsonFromString, UUID, Json, date } from 'io-ts-types'; + +export const route = h.httpRoute({ + path: '/basic-codecs', + method: 'GET', + request: h.httpRequest({ + query: { + // Base number types + numberParam: t.number, + + // Codec types that should be transformed + numberFromString: NumberFromString, + intFromString: IntFromString, + naturalFromString: NaturalFromString, + negativeFromString: NegativeFromString, + negativeIntFromString: NegativeIntFromString, + nonNegativeFromString: NonNegativeFromString, + nonPositiveFromString: NonPositiveFromString, + nonPositiveIntFromString: NonPositiveIntFromString, + nonZeroFromString: NonZeroFromString, + nonZeroIntFromString: NonZeroIntFromString, + positiveFromString: PositiveFromString, + zeroFromString: ZeroFromString, + + // io-ts-bigint + bigIntFromString: BigIntFromString, + negativeBigIntFromString: NegativeBigIntFromString, + nonNegativeBigIntFromString: NonNegativeBigIntFromString, + nonPositiveBigIntFromString: NonPositiveBigIntFromString, + nonZeroBigIntFromString: NonZeroBigIntFromString, + positiveBigIntFromString: PositiveBigIntFromString, + zeroBigIntFromString: ZeroBigIntFromString, + + // io-ts-types + booleanFromString: BooleanFromString, + dateFromISOString: DateFromISOString, + jsonFromString: JsonFromString, + uUID: UUID, + json: Json, + date: date + } + }), + response: { + 200: t.type({ + result: t.string + }) + } +}); +`; + +testCase('query params test', BASIC_CODEC_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/basic-codecs': { + get: { + parameters: [ + { + name: 'numberParam', + in: 'query', + required: true, + schema: { type: 'number' }, + }, + { + name: 'numberFromString', + in: 'query', + required: true, + schema: { type: 'number' }, + }, + { + name: 'intFromString', + in: 'query', + required: true, + schema: { type: 'integer' }, + }, + { + name: 'naturalFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + }, + { + name: 'negativeFromString', + in: 'query', + required: true, + schema: { + type: 'number', + maximum: 0, + exclusiveMaximum: true, + }, + }, + { + name: 'negativeIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + maximum: 0, + exclusiveMaximum: true, + }, + }, + { + name: 'nonNegativeFromString', + in: 'query', + required: true, + schema: { + type: 'number', + minimum: 0, + }, + }, + { + name: 'nonPositiveFromString', + in: 'query', + required: true, + schema: { + type: 'number', + maximum: 0, + }, + }, + { + name: 'nonPositiveIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + maximum: 0, + }, + }, + { + name: 'nonZeroFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + }, + { + name: 'nonZeroIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + }, + }, + { + name: 'positiveFromString', + in: 'query', + required: true, + schema: { + type: 'number', + minimum: 0, + exclusiveMinimum: true, + }, + }, + { + name: 'zeroFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + }, + { + name: 'bigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + }, + }, + { + name: 'negativeBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + maximum: -1, + }, + }, + { + name: 'nonNegativeBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + minimum: 0, + }, + }, + { + name: 'nonPositiveBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + maximum: 0, + }, + }, + { + name: 'nonZeroBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + }, + }, + { + name: 'positiveBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + minimum: 1, + }, + }, + { + name: 'zeroBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + }, + }, + { + name: 'booleanFromString', + in: 'query', + required: true, + schema: { + type: 'boolean', + }, + }, + { + name: 'dateFromISOString', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'date-time', + }, + }, + { + name: 'jsonFromString', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'json', + }, + }, + { + name: 'uUID', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'uuid', + }, + }, + { + name: 'json', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'json', + additionalProperties: true, + }, + }, + { + name: 'date', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'date', + }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); + +const ARRAY_CODEC_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; +import { NumberFromString, IntFromString } from 'io-ts-types'; + +export const route = h.httpRoute({ + path: '/array-codecs', + method: 'GET', + request: h.httpRequest({ + query: { + numberArray: t.array(t.number), + numberFromStringArray: t.array(NumberFromString), + intFromStringArray: t.array(IntFromString) + } + }), + response: { + 200: t.type({ + result: t.string + }) + } +}); +`; + +testCase('query params (array codec) transformation test', ARRAY_CODEC_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/array-codecs': { + get: { + parameters: [ + { + name: 'numberArray', + in: 'query', + required: true, + schema: { + type: 'array', + items: { type: 'number' }, + }, + }, + { + name: 'numberFromStringArray', + in: 'query', + required: true, + schema: { + type: 'array', + items: { type: 'number' }, + }, + }, + { + name: 'intFromStringArray', + in: 'query', + required: true, + schema: { + type: 'array', + items: { type: 'integer' }, + }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); + +// Test union types with codecs +const UNION_CODEC_TEST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; +import { NumberFromString, IntFromString } from 'io-ts-types'; + +export const route = h.httpRoute({ + path: '/union-codecs', + method: 'GET', + request: h.httpRequest({ + query: { + // Union types with codecs + mixedUnion: t.union([t.string, NumberFromString, IntFromString]), + numberUnion: t.union([t.number, NumberFromString]), + intUnion: t.union([t.number, IntFromString]) + } + }), + response: { + 200: t.type({ + result: t.string + }) + } +}); +`; + +testCase('query params (union codec) transformation test', UNION_CODEC_TEST, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0', + }, + paths: { + '/union-codecs': { + get: { + parameters: [ + { + name: 'mixedUnion', + in: 'query', + required: true, + schema: { + oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'integer' }], + }, + }, + { + name: 'numberUnion', + in: 'query', + required: true, + schema: { type: 'number' }, + }, + { + name: 'intUnion', + in: 'query', + required: true, + schema: { type: 'number' }, + }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + result: { type: 'string' }, + }, + required: ['result'], + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, +}); diff --git a/packages/openapi-generator/test/openapi/union.test.ts b/packages/openapi-generator/test/openapi/union.test.ts index 492a7633..aba02cab 100644 --- a/packages/openapi-generator/test/openapi/union.test.ts +++ b/packages/openapi-generator/test/openapi/union.test.ts @@ -201,7 +201,7 @@ testCase( in: 'query', required: true, schema: { - oneOf: [{ type: 'boolean' }, { type: 'string', format: 'number' }], + oneOf: [{ type: 'boolean' }, { type: 'number' }], }, }, { @@ -222,7 +222,7 @@ testCase( name: 'secondNonUnion', in: 'query', required: true, - schema: { type: 'string', format: 'number' }, + schema: { type: 'number' }, }, { name: 'thirdNonUnion', diff --git a/packages/openapi-generator/test/route.test.ts b/packages/openapi-generator/test/route.test.ts index 1caf337d..de559515 100644 --- a/packages/openapi-generator/test/route.test.ts +++ b/packages/openapi-generator/test/route.test.ts @@ -271,7 +271,6 @@ testCase('query param union route', QUERY_PARAM_UNION, { type: 'query', name: 'union', required: true, - explode: true, schema: { type: 'union', schemas: [ @@ -338,7 +337,6 @@ testCase('path param union route', PATH_PARAM_UNION, { type: 'query', name: 'union', required: true, - explode: true, schema: { type: 'union', schemas: [