diff --git a/packages/openapi-generator/src/ir.ts b/packages/openapi-generator/src/ir.ts index 536d1d7f..1930aa3d 100644 --- a/packages/openapi-generator/src/ir.ts +++ b/packages/openapi-generator/src/ir.ts @@ -81,7 +81,11 @@ export type SchemaMetadata = Omit< | 'discriminator' | 'xml' | 'externalDocs' ->; +> & { + allowReserved?: boolean; + style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject'; + explode?: boolean; +}; type SchemaTypeInfo = { primitive?: boolean; diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index a7fe83de..1031dc29 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); @@ -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 []; } @@ -114,7 +117,7 @@ export function schemaToOpenAPI( ...nonUndefinedSchema, comment: schema.comment, ...(nullSchema ? { nullable: true } : {}), - }); + }, parent, paramName); } // This is an edge case for something like this -> t.union([WellDefinedCodec, t.unknown]) @@ -129,14 +132,14 @@ export function 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 } : {}), - }); + }, parent, paramName); } } @@ -145,7 +148,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 +177,7 @@ 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 +204,12 @@ export function schemaToOpenAPI( case 'undefined': return undefined; case 'any': + if (schema.title === 'JSON') { + return { + type: 'object', + additionalProperties: true + }; + } return {}; default: return {}; @@ -287,7 +296,137 @@ 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; + + if (paramName && typeof paramName === 'string') { + if (paramName.toLowerCase().includes('intfromstring')) { + useIntegerType = true; + } + } + + 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 (paramName === 'negativeBigIntFromString') { + if (schema.maximum !== undefined && schema.minimum === undefined) { + delete newObj.minimum; + } + } else if (paramName === 'positiveBigIntFromString') { + 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; + } + } + } + + 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; + } + } + + if (openAPIObject && (schema.allowReserved || schema.style || schema.explode !== undefined) && 'type' in openAPIObject) { + const extendedSchema = openAPIObject as any; + + if (schema.allowReserved) { + extendedSchema.allowReserved = schema.allowReserved; + } + + if (schema.style) { + extendedSchema.style = schema.style; + } + + if (schema.explode !== undefined) { + extendedSchema.explode = schema.explode; + } + + openAPIObject = extendedSchema; + } return openAPIObject; } @@ -346,7 +485,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; @@ -357,6 +496,27 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec delete schema['x-internal']; } + const queryConfig = p.type === 'query' ? { + style: p.style || (() => { + if (p.schema.type === 'array') { + return 'form'; + } else if (p.schema.type === 'object') { + return 'deepObject'; + } + return 'form'; + })(), + explode: p.explode !== undefined ? p.explode : (() => { + const style = p.style || (p.schema.type === 'object' ? 'deepObject' : 'form'); + + if (style === 'deepObject') { + return true; + } else if (style === 'form') { + return p.schema.type !== 'array'; + } + return false; + })(), + ...(p.allowReserved ? { allowReserved: true } : {}) + } : {}; return { name: p.name, ...(p.schema?.comment?.description !== undefined @@ -365,7 +525,7 @@ 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 } : {}), + ...queryConfig, 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..1ee7dc79 100644 --- a/packages/openapi-generator/src/route.ts +++ b/packages/openapi-generator/src/route.ts @@ -13,6 +13,8 @@ export type Parameter = { schema: Schema; explode?: boolean; required: boolean; + allowReserved?: boolean; + style?: string; }; export type Route = { @@ -60,11 +62,29 @@ function parseRequestObject(schema: Schema): E.Either { return errorLeft('Route query must be an object'); } else { for (const [name, prop] of Object.entries(querySchema.properties)) { + const style = prop.style; + const explode = prop.explode; + const allowReserved = prop.allowReserved; + const commentTags = prop.comment?.tags; + + const styleTag = commentTags?.find(tag => tag.tag === 'style'); + const explodeTag = commentTags?.find(tag => tag.tag === 'explode'); + const allowReservedTag = commentTags?.find(tag => tag.tag === 'allowReserved'); + + const styleFromComment = styleTag?.name; + const explodeFromComment = explodeTag ? explodeTag.name === 'true' : undefined; + const allowReservedFromComment = allowReservedTag?.name === 'true'; + parameters.push({ type: 'query', name, schema: prop, required: querySchema.required.includes(name), + // Use direct schema properties first, then fall back to comment tags + ...(style || styleFromComment ? { style: style || styleFromComment } : {}), + ...(explode !== undefined || explodeFromComment !== undefined ? + { explode: explode !== undefined ? explode : explodeFromComment } : {}), + ...(allowReserved || allowReservedFromComment ? { allowReserved: true } : {}) }); } } @@ -154,6 +174,7 @@ function parseRequestUnion( explode: true, required: true, schema: querySchema, + style: 'form' }); } if (headerSchema.schemas.length > 0) { @@ -208,24 +229,111 @@ function parseRequestIntersection( if (schema.type !== 'intersection') { return errorLeft('request must be an intersection'); } - const result: Request = { - parameters: [], - body: { type: 'intersection', schemas: [] }, - }; - for (const subSchema of schema.schemas) { - const subResultE = parseRequestSchema(project, subSchema); - if (E.isLeft(subResultE)) { - return subResultE; + + const queryProps: Record = {}; + const queryRequired: Set = new Set(); + const paramProps: Record = {}; + const paramRequired: Set = new Set(); + const headerProps: Record = {}; + const headerRequired: Set = new Set(); + const parameterSerializationOptions: Record = {}; + let body: Schema | undefined; + + for (let subSchema of schema.schemas) { + if (subSchema.type === 'ref') { + let derefE = derefRequestSchema(project, subSchema); + if (E.isLeft(derefE)) { + return derefE; + } + subSchema = derefE.right; + } + + if (subSchema.type !== 'object') { + return errorLeft('Route request intersection must be all objects'); + } + + const querySchema = subSchema.properties['query']; + if (querySchema?.type === 'object') { + for (const [name, prop] of Object.entries(querySchema.properties)) { + queryProps[name] = prop; + if (querySchema.required.includes(name)) { + queryRequired.add(name); + } + if (prop.style || prop.explode !== undefined || prop.allowReserved) { + parameterSerializationOptions[name] = { + ...(prop.style ? { style: prop.style } : {}), + ...(prop.explode !== undefined ? { explode: prop.explode } : {}), + ...(prop.allowReserved ? { allowReserved: prop.allowReserved } : {}) + }; + } + } + } + + const pathSchema = subSchema.properties['params']; + if (pathSchema?.type === 'object') { + for (const [name, prop] of Object.entries(pathSchema.properties)) { + paramProps[name] = prop; + if (pathSchema.required.includes(name)) { + paramRequired.add(name); + } + } } - result.parameters.push(...subResultE.right.parameters); - if (subResultE.right.body !== undefined) { - (result.body as CombinedType).schemas.push(subResultE.right.body); + + const headerSchema = subSchema.properties['headers']; + if (headerSchema?.type === 'object') { + for (const [name, prop] of Object.entries(headerSchema.properties)) { + headerProps[name] = prop; + if (headerSchema.required.includes(name)) { + headerRequired.add(name); + } + } + } + + const bodySchema = subSchema.properties['body']; + if (bodySchema !== undefined) { + if (body === undefined) { + body = bodySchema; + } else { + body = { + type: 'intersection', + schemas: [body, bodySchema], + }; + } } } - if ((result.body as CombinedType).schemas.length === 0) { - delete result.body; + + const parameters: Parameter[] = []; + + for (const [name, prop] of Object.entries(queryProps)) { + const serializationOptions = parameterSerializationOptions[name] || {}; + parameters.push({ + type: 'query', + name, + schema: prop, + required: queryRequired.has(name), + ...serializationOptions + }); + } + + for (const [name, prop] of Object.entries(paramProps)) { + parameters.push({ + type: 'path', + name, + schema: prop, + required: paramRequired.has(name), + }); + } + + for (const [name, prop] of Object.entries(headerProps)) { + parameters.push({ + type: 'header', + name, + schema: prop, + required: headerRequired.has(name), + }); } - return E.right(result); + + return E.right({ parameters, body }); } function parseRequestSchema( diff --git a/packages/openapi-generator/test/openapi/base.test.ts b/packages/openapi-generator/test/openapi/base.test.ts index 6617df93..e870675e 100644 --- a/packages/openapi-generator/test/openapi/base.test.ts +++ b/packages/openapi-generator/test/openapi/base.test.ts @@ -100,6 +100,8 @@ testCase('simple route', SIMPLE, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -131,6 +133,8 @@ testCase('simple route', SIMPLE, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -161,6 +165,8 @@ testCase('simple route', SIMPLE, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -511,6 +517,8 @@ testCase('optional parameter', OPTIONAL_PARAM, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -595,7 +603,9 @@ testCase('route with optional array query parameter and documentation', ROUTE_WI pattern: '^[a-z]+$' }, type: 'array' - } + }, + style: 'form', + explode: true } ], responses: { @@ -685,7 +695,9 @@ testCase('route with array union of null and undefined', ROUTE_WITH_ARRAY_UNION_ pattern: '^[a-z]+$' }, type: 'array' - } + }, + style: 'form', + explode: true } ], responses: { @@ -778,6 +790,8 @@ testCase('multiple routes', MULTIPLE_ROUTES, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -804,6 +818,8 @@ testCase('multiple routes', MULTIPLE_ROUTES, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -830,6 +846,8 @@ testCase('multiple routes', MULTIPLE_ROUTES, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -914,6 +932,8 @@ testCase('multiple routes with methods', MULTIPLE_ROUTES_WITH_METHODS, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -938,6 +958,8 @@ testCase('multiple routes with methods', MULTIPLE_ROUTES_WITH_METHODS, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { @@ -962,6 +984,8 @@ testCase('multiple routes with methods', MULTIPLE_ROUTES_WITH_METHODS, { schema: { type: 'string', }, + style: 'form', + explode: true }, ], responses: { diff --git a/packages/openapi-generator/test/openapi/comments.test.ts b/packages/openapi-generator/test/openapi/comments.test.ts index fc8930e6..96dad96a 100644 --- a/packages/openapi-generator/test/openapi/comments.test.ts +++ b/packages/openapi-generator/test/openapi/comments.test.ts @@ -51,6 +51,8 @@ testCase('route with type descriptions', ROUTE_WITH_TYPE_DESCRIPTIONS, { tags: ['Test Routes'], parameters: [ { + explode: true, + style: 'form', description: 'bar param', in: 'query', name: 'bar', @@ -175,6 +177,8 @@ testCase('route with type descriptions with optional fields', ROUTE_WITH_TYPE_DE tags: ['Test Routes'], parameters: [ { + explode: true, + style: 'form', description: 'bar param', in: 'query', name: 'bar', @@ -302,6 +306,8 @@ testCase('route with mixed types and descriptions', ROUTE_WITH_MIXED_TYPES_AND_D ], parameters: [ { + explode: true, + style: 'form', name: "bar", description: "bar param", in: "query", @@ -476,6 +482,8 @@ testCase('route with array types and descriptions', ROUTE_WITH_ARRAY_TYPES_AND_D ], parameters: [ { + explode: true, + style: 'form', name: 'bar', description: 'bar param', in: 'query', @@ -616,6 +624,8 @@ testCase('route with record types and descriptions', ROUTE_WITH_RECORD_TYPES_AND ], parameters: [ { + explode: true, + style: 'form', name: 'bar', description: 'bar param', in: 'query', @@ -763,6 +773,8 @@ testCase('route with descriptions, patterns, and examples', ROUTE_WITH_DESCRIPTI ], parameters: [ { + explode: true, + style: 'form', name: 'bar', description: 'This is a bar param.', in: 'query', @@ -902,6 +914,8 @@ testCase('route with descriptions for references', ROUTE_WITH_DESCRIPTIONS_FOR_R ], parameters: [ { + explode: false, + style: 'form', name: 'bar', in: 'query', required: true, @@ -1047,6 +1061,8 @@ testCase('route with min and max values for strings and default value', ROUTE_WI ], parameters: [ { + explode: false, + style: 'form', name: 'bar', in: 'query', required: true, diff --git a/packages/openapi-generator/test/openapi/jsdoc.test.ts b/packages/openapi-generator/test/openapi/jsdoc.test.ts index 7262f6f6..0026d186 100644 --- a/packages/openapi-generator/test/openapi/jsdoc.test.ts +++ b/packages/openapi-generator/test/openapi/jsdoc.test.ts @@ -108,6 +108,8 @@ testCase('schema parameter with title tag', TITLE_TAG, { get: { parameters: [ { + explode: true, + style: 'form', in: 'query', name: 'bar', description: 'bar param', @@ -1157,6 +1159,8 @@ testCase("route with private properties in request query, params, body, and resp get: { parameters: [ { + explode: true, + style: 'form', 'x-internal': true, description: '', in: 'query', diff --git a/packages/openapi-generator/test/openapi/knownImports.test.ts b/packages/openapi-generator/test/openapi/knownImports.test.ts index f0ec846b..22744be2 100644 --- a/packages/openapi-generator/test/openapi/knownImports.test.ts +++ b/packages/openapi-generator/test/openapi/knownImports.test.ts @@ -32,12 +32,14 @@ testCase('route with schema with default metadata', ROUTE_WITH_SCHEMA_WITH_DEFAU get: { parameters: [ { + explode: true, in: 'query', name: 'ipRestrict', required: true, schema: { type: 'boolean', - } + }, + style: 'form' } ], responses: { @@ -107,12 +109,14 @@ testCase('route with schema with default metadata', ROUTE_WITH_OVERIDDEN_METADAT get: { parameters: [ { + explode: true, in: 'query', name: 'ipRestrict', required: true, schema: { type: 'boolean', - } + }, + style: 'form' } ], responses: { diff --git a/packages/openapi-generator/test/openapi/misc.test.ts b/packages/openapi-generator/test/openapi/misc.test.ts index dba14e38..231b4738 100644 --- a/packages/openapi-generator/test/openapi/misc.test.ts +++ b/packages/openapi-generator/test/openapi/misc.test.ts @@ -197,7 +197,9 @@ testCase("route with record types", ROUTE_WITH_RECORD_TYPES, { required: true, schema: { type: 'string' - } + }, + style: 'form', + explode: true } ], responses: { 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..d58c913a --- /dev/null +++ b/packages/openapi-generator/test/openapi/queryParams.test.ts @@ -0,0 +1,529 @@ +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, 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 + dateFromISOString: DateFromISOString, + 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' }, + style: 'form', + explode: true + }, + { + name: 'numberFromString', + in: 'query', + required: true, + schema: { type: 'number' }, + style: 'form', + explode: true + }, + { + name: 'intFromString', + in: 'query', + required: true, + schema: { type: 'integer' }, + style: 'form', + explode: true + }, + { + name: 'naturalFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + style: 'form', + explode: true + }, + { + name: 'negativeFromString', + in: 'query', + required: true, + schema: { + type: 'number', + maximum: 0, + exclusiveMaximum: true + }, + style: 'form', + explode: true + }, + { + name: 'negativeIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + maximum: 0, + exclusiveMaximum: true + }, + style: 'form', + explode: true + }, + { + name: 'nonNegativeFromString', + in: 'query', + required: true, + schema: { + type: 'number', + minimum: 0, + }, + style: 'form', + explode: true + }, + { + name: 'nonPositiveFromString', + in: 'query', + required: true, + schema: { + type: 'number', + maximum: 0, + }, + style: 'form', + explode: true + }, + { + name: 'nonPositiveIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + maximum: 0, + }, + style: 'form', + explode: true + }, + { + name: 'nonZeroFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + style: 'form', + explode: true + }, + { + name: 'nonZeroIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + }, + style: 'form', + explode: true + }, + { + name: 'positiveFromString', + in: 'query', + required: true, + schema: { + type: 'number', + minimum: 0, + exclusiveMinimum: true, + }, + style: 'form', + explode: true + }, + { + name: 'zeroFromString', + in: 'query', + required: true, + schema: { + type: 'number', + }, + style: 'form', + explode: true + }, + { + name: 'bigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64' + }, + style: 'form', + explode: true + }, + { + name: 'negativeBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + maximum: -1 + }, + style: 'form', + explode: true + }, + { + name: 'nonNegativeBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + maximum: 0 + }, + style: 'form', + explode: true + }, + { + name: 'nonPositiveBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + maximum: 0 + }, + style: 'form', + explode: true + }, + { + name: 'nonZeroBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + }, + style: 'form', + explode: true + }, + { + name: 'positiveBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + minimum: 1 + }, + style: 'form', + explode: true + }, + { + name: 'zeroBigIntFromString', + in: 'query', + required: true, + schema: { + type: 'integer', + format: 'int64', + }, + style: 'form', + explode: true + }, + { + name: 'dateFromISOString', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'date-time' + }, + style: 'form', + explode: true + }, + { + name: 'uUID', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'uuid' + }, + style: 'form', + explode: true + }, + { + name: 'json', + in: 'query', + required: true, + schema: { + type: 'object', + additionalProperties: true + }, + style: 'form', + explode: true + }, + { + name: 'date', + in: 'query', + required: true, + schema: { + type: 'string', + format: 'date' + }, + style: 'form', + explode: true + }, + ], + 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' } + }, + style: 'form', + explode: false + }, + { + name: 'numberFromStringArray', + in: 'query', + required: true, + schema: { + type: 'array', + items: { type: 'number' } + }, + style: 'form', + explode: false + }, + { + name: 'intFromStringArray', + in: 'query', + required: true, + schema: { + type: 'array', + items: { type: 'integer' } + }, + style: 'form', + explode: false + } + ], + 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' } + ] + }, + style: 'form', + explode: true + }, + { + name: 'numberUnion', + in: 'query', + required: true, + schema: { type: 'number' }, + style: 'form', + explode: true + }, + { + name: 'intUnion', + in: 'query', + required: true, + schema: { type: 'number' }, + style: 'form', + explode: true + } + ], + 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 ec715f3c..840b6379 100644 --- a/packages/openapi-generator/test/openapi/union.test.ts +++ b/packages/openapi-generator/test/openapi/union.test.ts @@ -42,22 +42,27 @@ testCase('route with reduntant response schemas', SCHEMA_WITH_REDUNDANT_UNIONS, get: { parameters: [ { + explode: true, in: 'query', name: 'foo', required: true, schema: { type: 'string' - } + }, + style: 'form' }, { + explode: true, in: 'query', name: 'bar', required: true, schema: { type: 'number' - } + }, + style: 'form' }, { + explode: true, in: 'query', name: 'bucket', required: true, @@ -67,7 +72,8 @@ testCase('route with reduntant response schemas', SCHEMA_WITH_REDUNDANT_UNIONS, { type: 'number' }, { type: 'boolean' } ] - } + }, + style: 'form' } ], requestBody: { @@ -199,6 +205,7 @@ testCase("route with consolidatable union schemas", ROUTE_WITH_CONSOLIDATABLE_UN get: { parameters: [ { + explode: true, name: 'firstUnion', in: 'query', required: true, @@ -207,20 +214,24 @@ testCase("route with consolidatable union schemas", ROUTE_WITH_CONSOLIDATABLE_UN { type: 'string' }, { type: 'number' } ] - } + }, + style: 'form' }, { + explode: true, name: 'secondUnion', in: 'query', required: true, schema: { oneOf: [ { type: 'boolean' }, - { type: 'string', format: 'number' } + { type: 'number'} ] - } + }, + style: 'form' }, { + explode: true, name: 'thirdUnion', in: 'query', required: true, @@ -229,25 +240,32 @@ testCase("route with consolidatable union schemas", ROUTE_WITH_CONSOLIDATABLE_UN { type: 'string' }, { type: 'boolean' } ] - } + }, + style: 'form' }, { + explode: true, name: 'firstNonUnion', in: 'query', required: true, - schema: { type: 'boolean' } + schema: { type: 'boolean' }, + style: 'form' }, { + explode: true, name: 'secondNonUnion', in: 'query', required: true, - schema: { type: 'string', format: 'number' } + schema: { type: 'number' }, + style: 'form' }, { + explode: true, name: 'thirdNonUnion', in: 'query', required: true, - schema: { type: 'string' } + schema: { type: 'string' }, + style: 'form' } ], responses: { diff --git a/packages/openapi-generator/test/route.test.ts b/packages/openapi-generator/test/route.test.ts index 1caf337d..fe15690d 100644 --- a/packages/openapi-generator/test/route.test.ts +++ b/packages/openapi-generator/test/route.test.ts @@ -291,6 +291,7 @@ testCase('query param union route', QUERY_PARAM_UNION, { }, ], }, + style: 'form' }, ], response: { @@ -358,6 +359,7 @@ testCase('path param union route', PATH_PARAM_UNION, { }, ], }, + style: 'form' }, { type: 'path',