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..b6941a81 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -1,5 +1,4 @@ import { OpenAPIV3 } from 'openapi-types'; - import { STATUS_CODES } from 'http'; import { parseCommentBlock } from './jsdoc'; import { optimize } from './optimize'; @@ -9,11 +8,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 +30,7 @@ export function schemaToOpenAPI( }; case 'integer': return { - type: 'number', + type: 'integer', ...(schema.enum ? { enum: schema.enum } : {}), ...defaultOpenAPIObject, }; @@ -46,14 +48,12 @@ export function schemaToOpenAPI( ...defaultOpenAPIObject, }; case 'array': - const innerSchema = schemaToOpenAPI(schema.items); + const innerSchema = schemaToOpenAPI(schema.items, parent, paramName); if (innerSchema === undefined) { return undefined; } - const { example, minItems, maxItems, ...rest } = defaultOpenAPIObject; const isArrayExample = example && Array.isArray(example); - return { type: 'array', ...(minItems ? { minItems } : {}), @@ -71,11 +71,10 @@ 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; } - return { ...acc, [name]: innerSchema }; }, {} as Record, @@ -85,7 +84,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 []; } @@ -96,25 +95,26 @@ export function schemaToOpenAPI( case 'union': let nullable = false; let oneOf: (OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject)[] = []; - // If there are two schemas and one of the schemas is undefined, that means the union is a case of `optional` type const undefinedSchema = schema.schemas.find((s) => s.type === 'undefined'); const nonUndefinedSchema = schema.schemas.find((s) => s.type !== 'undefined'); // If nullSchema exists, that means that the union is also nullable const nullSchema = schema.schemas.find((s) => s.type === 'null'); - // If any schema exists and it is in union with another schema - we can remove the any schema as an optimization const unknownSchema = schema.schemas.find((s) => s.type === 'any'); - // and we can just return the other schema (while attaching the comment to that schema) 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 +125,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 +153,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 +182,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) { @@ -183,7 +195,6 @@ export function schemaToOpenAPI( const properties = keys.enum.reduce((acc, key) => { return { ...acc, [key]: additionalProperties }; }, {}); - return { type: 'object', properties, @@ -192,7 +203,6 @@ export function schemaToOpenAPI( }; } } - return { type: 'object', additionalProperties, @@ -201,12 +211,18 @@ export function schemaToOpenAPI( case 'undefined': return undefined; case 'any': + if (schema.title === 'JSON') { + return { + type: 'string', + format: 'json', + additionalProperties: true, + }; + } return {}; default: return {}; } }; - /** * This function will return the field value parsed as the type of the schema. i.e. if the schema is a number, it will return the field as a JS number. * @@ -228,18 +244,18 @@ export function schemaToOpenAPI( return fieldValue; } }; - function buildDefaultOpenAPIObject(schema: Schema): OpenAPIV3.SchemaObject { const emptyBlock: Block = { description: '', tags: [], source: [], problems: [] }; const jsdoc = parseCommentBlock(schema.comment ?? emptyBlock); - const defaultValue = jsdoc?.tags?.default ?? schema.default; const example = jsdoc?.tags?.example ?? schema.example; const maxLength = jsdoc?.tags?.maxLength ?? schema.maxLength; const minLength = jsdoc?.tags?.minLength ?? schema.minLength; const pattern = jsdoc?.tags?.pattern ?? schema.pattern; - const minimum = jsdoc?.tags?.minimum ?? schema.maximum; - const maximum = jsdoc?.tags?.maximum ?? schema.minimum; + const minimum = + jsdoc?.tags?.minimum !== undefined ? jsdoc?.tags?.minimum : schema.minimum; + const maximum = + jsdoc?.tags?.maximum !== undefined ? jsdoc?.tags?.maximum : schema.maximum; const minItems = jsdoc?.tags?.minItems ?? schema.minItems; const maxItems = jsdoc?.tags?.maxItems ?? schema.maxItems; const minProperties = jsdoc?.tags?.minProperties ?? schema.minProperties; @@ -252,13 +268,10 @@ export function schemaToOpenAPI( const writeOnly = jsdoc?.tags?.writeOnly ?? schema.writeOnly; const format = jsdoc?.tags?.format ?? schema.format ?? schema.format; const title = jsdoc?.tags?.title ?? schema.title; - const keys = Object.keys(jsdoc?.tags || {}); - const deprecated = keys.includes('deprecated') || !!schema.deprecated; const isPrivate = keys.includes('private'); const description = schema.comment?.description ?? schema.description; - const defaultOpenAPIObject = { ...(defaultValue ? { default: parseField(schema, defaultValue) } : {}), ...(deprecated ? { deprecated: true } : {}), @@ -267,8 +280,8 @@ export function schemaToOpenAPI( ...(maxLength ? { maxLength: Number(maxLength) } : {}), ...(minLength ? { minLength: Number(minLength) } : {}), ...(pattern ? { pattern } : {}), - ...(minimum ? { minimum: Number(minimum) } : {}), - ...(maximum ? { maximum: Number(maximum) } : {}), + ...(minimum !== undefined ? { minimum: Number(minimum) } : {}), + ...(maximum !== undefined ? { maximum: Number(maximum) } : {}), ...(minItems ? { minItems: Number(minItems) } : {}), ...(maxItems ? { maxItems: Number(maxItems) } : {}), ...(minProperties ? { minProperties: Number(minProperties) } : {}), @@ -283,15 +296,130 @@ export function schemaToOpenAPI( ...(title ? { title } : {}), ...(isPrivate ? { 'x-internal': true } : {}), }; - 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; } - function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObject] { const jsdoc = route.comment !== undefined ? parseCommentBlock(route.comment) : {}; const operationId = jsdoc.tags?.operationId; @@ -299,7 +427,6 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec const isInternal = jsdoc.tags?.private !== undefined; const isUnstable = jsdoc.tags?.unstable !== undefined; const example = jsdoc.tags?.example; - const knownTags = new Set([ 'operationId', 'summary', @@ -319,7 +446,6 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec }, {}, ); - const requestBody = route.body === undefined ? {} @@ -330,7 +456,6 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec }, }, }; - return [ route.path, route.method.toLowerCase(), @@ -346,17 +471,15 @@ 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; } - const isPrivate = schema && 'x-internal' in schema; if (isPrivate) { delete schema['x-internal']; } - return { name: p.name, ...(p.schema?.comment?.description !== undefined @@ -365,14 +488,12 @@ 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 }; }), ...requestBody, responses: Object.entries(route.response).reduce((acc, [code, response]) => { const description = STATUS_CODES[code] ?? ''; - return { ...acc, [Number(code)]: { @@ -389,7 +510,6 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec }, ]; } - export function convertRoutesToOpenAPI( info: OpenAPIV3.InfoObject, servers: OpenAPIV3.ServerObject[], @@ -405,7 +525,6 @@ export function convertRoutesToOpenAPI( }, {} as Record>, ); - const openapiSchemas = Object.entries(schemas).reduce( (acc, [name, schema]) => { const openapiSchema = schemaToOpenAPI(schema); @@ -430,7 +549,6 @@ export function convertRoutesToOpenAPI( }, {} as Record, ); - const sortedPaths = Object.keys(paths) .sort((a, b) => a.localeCompare(b)) .reduce( @@ -444,13 +562,11 @@ export function convertRoutesToOpenAPI( }, {} as Record, ); - acc[key] = sortedMethods; return acc; }, {} as Record, ); - return { openapi: '3.0.3', info, 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/openapiSchemaTest.ts b/packages/openapi-generator/test/openapi/openapiSchemaTest.ts new file mode 100644 index 00000000..dc0d3a80 --- /dev/null +++ b/packages/openapi-generator/test/openapi/openapiSchemaTest.ts @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { schemaToOpenAPI } from '../../src/openapi'; +import { OpenAPIV3 } from 'openapi-types'; +import { Schema } from '../../src/ir'; + +test('numeric constraints are properly mapped', () => { + const schema: Schema = { + type: 'number', + minimum: 0, + maximum: 100, + multipleOf: 5, + comment: { + description: 'A number with constraints', + tags: [], + source: [], + problems: [], + }, + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'number'); + assert.equal(result.description, 'A number with constraints'); + + assert.equal(result.minimum, 0); + assert.equal(result.maximum, 100); + assert.equal(result.multipleOf, 5); +}); + +test('exclusive numeric constraints are properly mapped', () => { + const schema: Schema = { + type: 'number', + minimum: 0, + maximum: 100, + exclusiveMinimum: true, + exclusiveMaximum: true, + comment: { + description: 'A number with exclusive constraints', + tags: [], + source: [], + problems: [], + }, + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'number'); + assert.equal(result.description, 'A number with exclusive constraints'); + + assert.equal(result.minimum, 0); + assert.equal(result.maximum, 100); + assert.equal(result.exclusiveMinimum, true); + assert.equal(result.exclusiveMaximum, true); +}); + +test('string-encoded numeric types are properly mapped', () => { + const schema: Schema = { + type: 'string', + format: 'number', + decodedType: 'number', + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'number'); +}); + +test('string-encoded integer types are properly mapped', () => { + const schema: Schema = { + type: 'string', + format: 'integer', + decodedType: 'number', + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'integer'); +}); + +test('BigInt types are properly mapped', () => { + const schema: Schema = { + type: 'string', + format: 'number', + decodedType: 'bigint', + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'integer'); + assert.equal(result.format, 'int64'); +}); + +test('BigInt types with constraints are properly mapped', () => { + const schema: Schema = { + type: 'string', + format: 'number', + decodedType: 'bigint', + minimum: 0, + maximum: 1000000000000000000, + }; + + const result = schemaToOpenAPI(schema) as OpenAPIV3.SchemaObject; + + assert.equal(result.type, 'integer'); + assert.equal(result.format, 'int64'); + assert.equal(result.minimum, 0); + assert.equal(result.maximum, 1000000000000000000); +}); 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..8dec3a28 --- /dev/null +++ b/packages/openapi-generator/test/openapi/queryParams.test.ts @@ -0,0 +1,480 @@ +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: [