From 3d0304ad75c1bfa7ad0ef841c4303c2dcf504225 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sun, 30 Nov 2025 14:06:51 +0000 Subject: [PATCH 1/6] fix: always extract request bodies --- packages/openapi-code-generator/src/core/input.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 073b7f08..de46e129 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -402,6 +402,7 @@ export class Input { hasMultipleMediaTypes ? mediaTypeToIdentifier(mediaType) : "" }${suffix}` + const result = this.schemaNormalizer.normalize(schema) const shouldCreateVirtualType = From 56ad255b06414bd4dcae1cf8fcb967584731ac36 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 29 Nov 2025 10:58:57 +0000 Subject: [PATCH 2/6] refactor: split input into more testable classes --- .../openapi-code-generator/src/core/input.ts | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index de46e129..d1c9bacc 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -374,20 +374,20 @@ export class Input { return Object.fromEntries( filtered.map(([contentType, mediaType]) => { - return [ - contentType, - { - schema: this.normalizeMediaTypeSchema( - operationId, - contentType, - mediaType.schema, - suffix, - hasMultipleMediaTypes, - ), - encoding: mediaType.encoding, - }, - ] - }), + return [ + contentType, + { + schema: this.normalizeMediaTypeSchema( + operationId, + contentType, + mediaType.schema, + suffix, + hasMultipleMediaTypes, + ), + encoding: mediaType.encoding, + }, + ] + }), ) } @@ -402,7 +402,6 @@ export class Input { hasMultipleMediaTypes ? mediaTypeToIdentifier(mediaType) : "" }${suffix}` - const result = this.schemaNormalizer.normalize(schema) const shouldCreateVirtualType = From 7848aa656488deb723f59e0c5e6ade6d7cb5100f Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 29 Nov 2025 10:59:13 +0000 Subject: [PATCH 3/6] chore: ignore build script --- pnpm-workspace.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dfd78b34..1ccf8372 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ ignoredBuiltDependencies: - '@swc/core' - esbuild - lmdb + - msgpackr-extract - nx - sharp - unrs-resolver From ee07f01288efac0e3ed6577f58c77759077cec21 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 29 Nov 2025 11:58:16 +0000 Subject: [PATCH 4/6] fix: adding tests, backwards compat with openapi 3.0 --- .../src/core/input.spec.ts | 14 +++-- .../openapi-code-generator/src/core/input.ts | 53 ++++++++++++++----- .../src/core/openapi-types-normalized.ts | 4 +- .../schema-builders/joi-schema-builder.ts | 8 +-- .../schema-builders/zod-v3-schema-builder.ts | 8 +-- .../schema-builders/zod-v4-schema-builder.ts | 8 +-- 6 files changed, 60 insertions(+), 35 deletions(-) diff --git a/packages/openapi-code-generator/src/core/input.spec.ts b/packages/openapi-code-generator/src/core/input.spec.ts index ad884613..12e17bb8 100644 --- a/packages/openapi-code-generator/src/core/input.spec.ts +++ b/packages/openapi-code-generator/src/core/input.spec.ts @@ -70,8 +70,8 @@ describe("core/input - SchemaNormalizer", () => { enum: undefined, exclusiveMaximum: undefined, exclusiveMinimum: undefined, - maximum: undefined, - minimum: undefined, + inclusiveMaximum: undefined, + inclusiveMinimum: undefined, multipleOf: undefined, nullable: false, readOnly: false, @@ -244,15 +244,14 @@ describe("core/input - SchemaNormalizer", () => { ...base.number, format: "int64", multipleOf: 2, - maximum: 4, - minimum: -2, + inclusiveMaximum: 4, + inclusiveMinimum: -2, exclusiveMaximum: 5, exclusiveMinimum: -3, }) }) - // todo: implement - it.skip("handles openapi 3.0 boolean exclusiveMaximum / exclusiveMinimum modifiers (true)", () => { + it("handles openapi 3.0 boolean exclusiveMaximum / exclusiveMinimum modifiers (true)", () => { const actual = schemaNormalizer.normalize({ type: "number", format: "int64", @@ -272,8 +271,7 @@ describe("core/input - SchemaNormalizer", () => { }) }) - // todo: implement - it.skip("handles openapi 3.0 boolean exclusiveMaximum / exclusiveMinimum modifiers (false)", () => { + it("handles openapi 3.0 boolean exclusiveMaximum / exclusiveMinimum modifiers (false)", () => { const actual = schemaNormalizer.normalize({ type: "number", format: "int64", diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index d1c9bacc..4c6380d8 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -685,18 +685,46 @@ export class SchemaNormalizer { Number.isFinite(it), ) - let exclusiveMaximum = schemaObject.exclusiveMaximum + const calcMaximums = () => { + // draft-wright-json-schema-validation-01 changed "exclusiveMaximum"/"exclusiveMinimum" from boolean modifiers + // of "maximum"/"minimum" to independent numeric fields. + // we need to support both. + if (typeof schemaObject.exclusiveMaximum === "boolean") { + if (schemaObject.exclusiveMaximum) { + return { + exclusiveMaximum: schemaObject.maximum, + inclusiveMaximum: undefined, + } + } else { + return { + exclusiveMaximum: undefined, + inclusiveMaximum: schemaObject.maximum, + } + } + } - if (typeof exclusiveMaximum === "boolean") { - logger.warn("boolean exclusiveMaximum not yet supported - ignoring") - exclusiveMaximum = undefined + return {exclusiveMaximum: schemaObject.exclusiveMaximum} } - let exclusiveMinimum = schemaObject.exclusiveMinimum + const calcMinimums = () => { + // draft-wright-json-schema-validation-01 changed "exclusiveMaximum"/"exclusiveMinimum" from boolean modifiers + // of "maximum"/"minimum" to independent numeric fields. + // we need to support both. + if (typeof schemaObject.exclusiveMinimum === "boolean") { + if (schemaObject.exclusiveMinimum) { + return { + exclusiveMinimum: schemaObject.minimum, + inclusiveMinimum: undefined, + } + } else { + return { + exclusiveMinimum: undefined, + inclusiveMinimum: schemaObject.minimum, + } + } + } - if (typeof exclusiveMinimum === "boolean") { - logger.warn("boolean exclusiveMinimum not yet supported - ignoring") - exclusiveMinimum = undefined + return {exclusiveMinimum: schemaObject.exclusiveMinimum} } return { @@ -706,12 +734,11 @@ export class SchemaNormalizer { // todo: https://github.com/mnahkies/openapi-code-generator/issues/51 format: schemaObject.format, enum: enumValues.length ? enumValues : undefined, - exclusiveMaximum, - exclusiveMinimum, - maximum: schemaObject.maximum, - minimum: schemaObject.minimum, + inclusiveMaximum: schemaObject.maximum, + inclusiveMinimum: schemaObject.minimum, multipleOf: schemaObject.multipleOf, - + ...calcMaximums(), + ...calcMinimums(), "x-enum-extensibility": enumValues.length ? (schemaObject["x-enum-extensibility"] ?? self.config.enumExtensibility) diff --git a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts index 3700f5b5..f522c7e0 100644 --- a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts +++ b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts @@ -35,8 +35,8 @@ export interface IRModelNumeric extends IRModelBase { enum?: number[] | undefined exclusiveMaximum?: number | undefined exclusiveMinimum?: number | undefined - maximum?: number | undefined - minimum?: number | undefined + inclusiveMaximum?: number | undefined + inclusiveMinimum?: number | undefined multipleOf?: number | undefined "x-enum-extensibility"?: "open" | "closed" | undefined diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts index 49639bff..2ebd236e 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/joi-schema-builder.ts @@ -246,13 +246,13 @@ export class JoiBuilder extends AbstractSchemaBuilder< : undefined, Number.isFinite(model.exclusiveMinimum) ? `greater(${model.exclusiveMinimum})` - : Number.isFinite(model.minimum) - ? `min(${model.minimum})` + : Number.isFinite(model.inclusiveMinimum) + ? `min(${model.inclusiveMinimum})` : undefined, Number.isFinite(model.exclusiveMaximum) ? `less(${model.exclusiveMaximum})` - : Number.isFinite(model.maximum) - ? `max(${model.maximum})` + : Number.isFinite(model.inclusiveMaximum) + ? `max(${model.inclusiveMaximum})` : undefined, ] .filter(isDefined) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts index 8320e2b8..d497263e 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v3-schema-builder.ts @@ -259,13 +259,13 @@ export class ZodV3Builder extends AbstractSchemaBuilder< : undefined, Number.isFinite(model.exclusiveMinimum) ? `gt(${model.exclusiveMinimum})` - : Number.isFinite(model.minimum) - ? `min(${model.minimum})` + : Number.isFinite(model.inclusiveMinimum) + ? `min(${model.inclusiveMinimum})` : undefined, Number.isFinite(model.exclusiveMaximum) ? `lt(${model.exclusiveMaximum})` - : Number.isFinite(model.maximum) - ? `max(${model.maximum})` + : Number.isFinite(model.inclusiveMaximum) + ? `max(${model.inclusiveMaximum})` : undefined, ] .filter(isDefined) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts index ffa0341b..45591587 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/zod-v4-schema-builder.ts @@ -262,13 +262,13 @@ export class ZodV4Builder extends AbstractSchemaBuilder< : undefined, Number.isFinite(model.exclusiveMinimum) ? `gt(${model.exclusiveMinimum})` - : Number.isFinite(model.minimum) - ? `min(${model.minimum})` + : Number.isFinite(model.inclusiveMinimum) + ? `min(${model.inclusiveMinimum})` : undefined, Number.isFinite(model.exclusiveMaximum) ? `lt(${model.exclusiveMaximum})` - : Number.isFinite(model.maximum) - ? `max(${model.maximum})` + : Number.isFinite(model.inclusiveMaximum) + ? `max(${model.inclusiveMaximum})` : undefined, ] .filter(isDefined) From 81cc7b9aba75f9c663eed17d0e1f31d4eee5128b Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sat, 29 Nov 2025 12:10:41 +0000 Subject: [PATCH 5/6] fix: build --- .../common/schema-builders/abstract-schema-builder.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts index a87b44d1..b4a6bb12 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts @@ -4,7 +4,7 @@ import { } from "../../../core/dependency-graph" import type {Input} from "../../../core/input" import {logger} from "../../../core/logger" -import type {Reference} from "../../../core/openapi-types" +import type {Reference, Schema} from "../../../core/openapi-types" import type { IRModelArray, IRModelBase, @@ -196,7 +196,7 @@ export abstract class AbstractSchemaBuilder< // todo: rethink the isAnonymous parameter - it would be better to just provide more context fromModel( - maybeModel: MaybeIRModel, + maybeModel: Reference | Schema, required: boolean, isAnonymous = false, nullable = false, From 80066393c6c305772a1911904f8b75e5a1a34a67 Mon Sep 17 00:00:00 2001 From: Michael Nahkies Date: Sun, 30 Nov 2025 13:12:47 +0000 Subject: [PATCH 6/6] wip: decouple --- .../openapi-code-generator/src/core/input.ts | 170 +++++++++++++----- .../src/core/openapi-types-normalized.ts | 24 ++- .../client/client-operation-builder.ts | 30 ++-- .../abstract-schema-builder.ts | 49 +---- .../schema-builder.test-utils.ts | 4 +- .../src/typescript/common/type-builder.ts | 4 +- .../typescript/common/typescript-common.ts | 7 +- .../server/server-operation-builder.ts | 137 +++++--------- .../typescript-express-router-builder.ts | 20 +-- .../typescript-koa-router-builder.spec.ts | 14 +- .../typescript-koa-router-builder.ts | 21 ++- 11 files changed, 253 insertions(+), 227 deletions(-) diff --git a/packages/openapi-code-generator/src/core/input.ts b/packages/openapi-code-generator/src/core/input.ts index 4c6380d8..eb97c1fe 100644 --- a/packages/openapi-code-generator/src/core/input.ts +++ b/packages/openapi-code-generator/src/core/input.ts @@ -23,6 +23,7 @@ import type { IRModelObject, IRModelString, IROperation, + IROperationParams, IRParameter, IRParameterBase, IRParameterCookie, @@ -31,6 +32,7 @@ import type { IRParameterQuery, IRPreprocess, IRRef, + IRRequestBody, IRResponse, IRServer, IRServerVariable, @@ -59,7 +61,7 @@ export class Input { constructor( readonly loader: OpenapiLoader, readonly config: InputConfig, - private readonly schemaNormalizer = new SchemaNormalizer(config), + readonly schemaNormalizer = new SchemaNormalizer(config), private readonly parameterNormalizer = new ParameterNormalizer( schemaNormalizer, ), @@ -89,8 +91,10 @@ export class Input { return Object.fromEntries( Object.entries(schemas).map(([name, maybeSchema]) => { - // TODO: double normalization? - return [name, this.schema(this.schemaNormalizer.normalize(maybeSchema))] + const schema = this.schemaNormalizer.normalize( + this.loader.schema(maybeSchema), + ) + return [name, schema] }), ) } @@ -119,8 +123,6 @@ export class Input { )) { paths = this.loader.paths(paths) - const params = this.normalizeParameters(paths.parameters) - const additionalAttributes = Object.fromEntries( Object.entries(paths).filter( ([key]) => key !== "parameters" && !isHttpMethod(key), @@ -154,6 +156,10 @@ export class Input { throw new Error("callbacks are not supported") } + const parameters = (paths.parameters ?? []).concat( + definition.parameters ?? [], + ) + result.push({ ...additionalAttributes, route, @@ -161,9 +167,7 @@ export class Input { servers: this.normalizeServers( coalesce(definition.servers, paths.servers, []), ), - parameters: params.concat( - this.normalizeParameters(definition.parameters), - ), + params: this.normalizeParameters(operationId, parameters), operationId, tags: definition.tags ?? [], requestBody: this.normalizeRequestBodyObject( @@ -235,9 +239,13 @@ export class Input { ).map(([name, operations]) => ({name, operations})) } - schema(maybeRef: Reference | Schema): IRModel { - const schema = this.loader.schema(maybeRef) - return this.schemaNormalizer.normalize(schema) + schema(maybeRef: MaybeIRModel): IRModel { + if (isRef(maybeRef)) { + const schema = this.loader.schema(maybeRef) + return this.schemaNormalizer.normalize(schema) + } + + return maybeRef } preprocess(maybePreprocess: Reference | xInternalPreproccess): IRPreprocess { @@ -288,7 +296,7 @@ export class Input { private normalizeRequestBodyObject( operationId: string, requestBody?: RequestBody | Reference, - ) { + ): IRRequestBody | undefined { if (!requestBody) { return undefined } @@ -340,11 +348,73 @@ export class Input { } private normalizeParameters( + operationId: string, parameters: (Parameter | Reference)[] = [], - ): IRParameter[] { - return parameters - .map((it) => this.loader.parameter(it)) - .map((it) => this.parameterNormalizer.normalizeParameter(it)) + ): IROperationParams { + const allParameters = parameters.map((it) => this.loader.parameter(it)) + + const pathParameters = allParameters.filter((it) => it.in === "path") + const queryParameters = allParameters.filter((it) => it.in === "query") + const headerParameters = allParameters.filter((it) => it.in === "header") + + const normalizedParameters = allParameters.map((it) => + this.parameterNormalizer.normalizeParameter(it), + ) + + return { + all: normalizedParameters, + path: { + name: `${operationId}ParamSchema`, + list: normalizedParameters.filter((it) => it.in === "path"), + $ref: this.loader.addVirtualType( + operationId, + upperFirst(`${operationId}ParamSchema`), + this.reduceParametersToOpenApiSchema(pathParameters), + ), + }, + query: { + name: `${operationId}QuerySchema`, + list: normalizedParameters.filter((it) => it.in === "query"), + $ref: this.loader.addVirtualType( + operationId, + upperFirst(`${operationId}QuerySchema`), + this.reduceParametersToOpenApiSchema(queryParameters), + ), + }, + header: { + name: `${operationId}RequestHeaderSchema`, + list: normalizedParameters.filter((it) => it.in === "header"), + $ref: this.loader.addVirtualType( + operationId, + upperFirst(`${operationId}RequestHeaderSchema`), + this.reduceParametersToOpenApiSchema(headerParameters), + ), + }, + } + } + + private reduceParametersToOpenApiSchema( + parameters: Parameter[], + ): SchemaObject { + const properties: Record = {} + const required: string[] = [] + + for (const parameter of parameters) { + properties[parameter.name] = parameter.schema + + if (parameter.required) { + required.push(parameter.name) + } + } + + return { + isIRModel: false, + type: "object", + properties, + required, + additionalProperties: false, + nullable: false, + } } private normalizeOperationId( @@ -374,20 +444,20 @@ export class Input { return Object.fromEntries( filtered.map(([contentType, mediaType]) => { - return [ - contentType, - { - schema: this.normalizeMediaTypeSchema( - operationId, - contentType, - mediaType.schema, - suffix, - hasMultipleMediaTypes, - ), - encoding: mediaType.encoding, - }, - ] - }), + return [ + contentType, + { + schema: this.normalizeMediaTypeSchema( + operationId, + contentType, + mediaType.schema, + suffix, + hasMultipleMediaTypes, + ), + encoding: mediaType.encoding, + }, + ] + }), ) } @@ -395,7 +465,7 @@ export class Input { operationId: string, mediaType: string, schema: Schema | Reference, - suffix: string, + suffix: "RequestBody" | `${string}Response`, hasMultipleMediaTypes: boolean, ): MaybeIRModel { const syntheticName = `${upperFirst(operationId)}${ @@ -405,15 +475,16 @@ export class Input { const result = this.schemaNormalizer.normalize(schema) const shouldCreateVirtualType = - this.config.extractInlineSchemas && + (this.config.extractInlineSchemas || suffix === "RequestBody") && !isRef(result) && + !isRef(schema) && (result.type === "object" || (result.type === "array" && !isRef(result.items) && result.items.type === "object")) return shouldCreateVirtualType - ? this.loader.addVirtualType(operationId, syntheticName, result) + ? this.loader.addVirtualType(operationId, syntheticName, schema) : result } } @@ -461,6 +532,17 @@ export class ParameterNormalizer { throwUnsupportedStyle(style) } + // todo: add if dereferenced(base.schema).type === "array + /* + + "x-internal-preprocess": { + deserialize: { + fn: "(it: unknown) => Array.isArray(it) || it === undefined ? it : [it]", + }, + }, + + */ + return { ...base, in: "query", @@ -571,25 +653,30 @@ export class SchemaNormalizer { return schemaObject satisfies IRRef } + if (Reflect.get(schemaObject, "isIRModel")) { + throw new Error("double normalization!") + } + // TODO: HACK: translates a type array into a a oneOf - unsure if this makes sense, // or is the cleanest way to do it. I'm fairly sure this will work fine // for most things though. if (Array.isArray(schemaObject.type)) { const nullable = Boolean(schemaObject.type.find((it) => it === "null")) return self.normalize({ + isIRModel: false, + type: "object", oneOf: schemaObject.type .filter((it) => it !== "null") - .map((it) => - self.normalize({ - ...schemaObject, - type: it, - nullable, - }), - ), + .map((it) => ({ + ...schemaObject, + type: it, + nullable, + })), }) } const base: IRModelBase = { + isIRModel: true, nullable: schemaObject.nullable || false, readOnly: schemaObject.readOnly || false, default: schemaObject.default, @@ -795,10 +882,11 @@ export class SchemaNormalizer { type: "never", } } - default: + default: { throw new Error( `unsupported type '${schemaObject.type satisfies never}'`, ) + } } function normalizeProperties( diff --git a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts index f522c7e0..0c59f427 100644 --- a/packages/openapi-code-generator/src/core/openapi-types-normalized.ts +++ b/packages/openapi-code-generator/src/core/openapi-types-normalized.ts @@ -1,4 +1,4 @@ -import type {Style} from "./openapi-types" +import type {Reference, Style} from "./openapi-types" import type {HttpMethod} from "./utils" export interface IRRef { @@ -7,6 +7,7 @@ export interface IRRef { } export interface IRModelBase { + isIRModel: true // Note: meaningless for top level objects, maybe we can exclude these somehow in that case nullable: boolean /* false */ readOnly: boolean /* false */ @@ -166,14 +167,29 @@ export type IRParameter = | IRParameterPath | IRParameterQuery | IRParameterHeader - | IRParameterCookie | IRParameterRequestBody + | IRParameterCookie + +/** + * name - variable name for generated code + * $ref - location of the schema encapsulating params into an object + * list - list of the parameters + */ +export interface IROperationParams { + all: IRParameter[] + path: {name: string; list: IRParameterPath[]; $ref: Reference} + query: {name: string; list: IRParameterQuery[]; $ref: Reference} + header: {name: string; list: IRParameterHeader[]; $ref: Reference} +} export interface IROperation { + operationId: string + route: string method: HttpMethod - parameters: IRParameter[] - operationId: string + + params: IROperationParams + tags: string[] requestBody: IRRequestBody | undefined responses: diff --git a/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts b/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts index 5fd4a092..ea9dab3d 100644 --- a/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/client/client-operation-builder.ts @@ -62,7 +62,7 @@ export class ClientOperationBuilder { } routeToTemplateString(paramName = "p"): string { - const {route, parameters} = this.operation + const {route, params} = this.operation const placeholders = extractPlaceholders(route) return placeholders.reduce((result, {placeholder, wholeString}) => { @@ -72,9 +72,7 @@ export class ClientOperationBuilder { ) } - const parameter = parameters.find( - (it) => it.name === placeholder && it.in === "path", - ) + const parameter = params.path.list.find((it) => it.name === placeholder) if (!parameter) { throw new Error( @@ -91,11 +89,11 @@ export class ClientOperationBuilder { } methodParameter(): MethodParameterDefinition | undefined { - const {parameters} = this.operation + const {params} = this.operation const requestBody = this.requestBodyAsParameter() return combineParams( - [...parameters, requestBody?.parameter].filter(isDefined).map((it) => ({ + [...params.all, requestBody?.parameter].filter(isDefined).map((it) => ({ name: `${camelCase(it.name)}`, type: this.models.schemaObjectToType(it.schema), required: it.required, @@ -132,11 +130,8 @@ export class ClientOperationBuilder { } queryString(): string { - const {parameters} = this.operation - // todo: consider style / explode / allowReserved etc here - return parameters - .filter((it) => it.in === "query") + return this.operation.params.query.list .map((it) => `'${it.name}': ${this.paramName(it.name)}`) .join(",\n") } @@ -146,11 +141,9 @@ export class ClientOperationBuilder { }: { nullContentTypeValue: "undefined" | "false" }): string { - const {parameters} = this.operation - - const paramHeaders = parameters - .filter((it) => it.in === "header") - .map((it) => `'${it.name}': ${this.paramName(it.name)}`) + const paramHeaders = this.operation.params.header.list.map( + (it) => `'${it.name}': ${this.paramName(it.name)}`, + ) const hasAcceptHeader = this.hasHeader("Accept") const hasContentTypeHeader = this.hasHeader("Content-Type") @@ -175,11 +168,8 @@ export class ClientOperationBuilder { } hasHeader(name: string): boolean { - const {parameters} = this.operation - - const parameter = parameters.find( - (it) => - it.in === "header" && it.name.toLowerCase() === name.toLowerCase(), + const parameter = this.operation.params.header.list.find( + (it) => it.name.toLowerCase() === name.toLowerCase(), ) return Boolean(parameter) diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts index b4a6bb12..37d657ea 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/abstract-schema-builder.ts @@ -4,7 +4,7 @@ import { } from "../../../core/dependency-graph" import type {Input} from "../../../core/input" import {logger} from "../../../core/logger" -import type {Reference, Schema} from "../../../core/openapi-types" +import type {Reference} from "../../../core/openapi-types" import type { IRModelArray, IRModelBase, @@ -157,46 +157,9 @@ export abstract class AbstractSchemaBuilder< protected abstract schemaFromRef(reference: Reference): ExportDefinition - fromParameters(parameters: IRParameter[]): string { - const model: IRModelObject = { - type: "object", - required: [], - properties: {}, - allOf: [], - oneOf: [], - anyOf: [], - readOnly: false, - nullable: false, - additionalProperties: false, - } - - for (const parameter of parameters) { - if (parameter.required) { - model.required.push(parameter.name) - } - - const dereferenced = this.input.loader.schema(parameter.schema) - - if (parameter.in === "query" && dereferenced.type === "array") { - model.properties[parameter.name] = { - ...parameter.schema, - "x-internal-preprocess": { - deserialize: { - fn: "(it: unknown) => Array.isArray(it) || it === undefined ? it : [it]", - }, - }, - } - } else { - model.properties[parameter.name] = parameter.schema - } - } - - return this.fromModel(model, true, true) - } - // todo: rethink the isAnonymous parameter - it would be better to just provide more context fromModel( - maybeModel: Reference | Schema, + maybeModel: MaybeIRModel, required: boolean, isAnonymous = false, nullable = false, @@ -234,7 +197,11 @@ export abstract class AbstractSchemaBuilder< return result } - const model = this.input.schema(maybeModel) + if (!Reflect.get(maybeModel, "isIRModel")) { + throw new Error("passed raw schema") + } + + const model = maybeModel switch (model.type) { case "string": @@ -263,7 +230,7 @@ export abstract class AbstractSchemaBuilder< // Note: for zod in particular it's desirable to use merge over intersection // where possible, as it returns a more malleable schema const isMergable = model.allOf - .map((it) => this.input.schema(it)) + .map((it) => (isRef(it) ? this.input.schema(it) : it)) .every( (it) => it.type === "object" && diff --git a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts index 009a010b..18af1734 100644 --- a/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts +++ b/packages/openapi-code-generator/src/typescript/common/schema-builders/schema-builder.test-utils.ts @@ -68,7 +68,9 @@ export function schemaBuilderTestHarness( const schema = schemaBuilder .withImports(imports) .fromModel( - isRef(maybeSchema) ? maybeSchema : input.schema(maybeSchema), + isRef(maybeSchema) + ? maybeSchema + : input.schemaNormalizer.normalize(maybeSchema), required, ) diff --git a/packages/openapi-code-generator/src/typescript/common/type-builder.ts b/packages/openapi-code-generator/src/typescript/common/type-builder.ts index 9b9b7deb..c33bde2a 100644 --- a/packages/openapi-code-generator/src/typescript/common/type-builder.ts +++ b/packages/openapi-code-generator/src/typescript/common/type-builder.ts @@ -280,7 +280,9 @@ export class TypeBuilder implements ICompilable { } isEmptyObject(schemaObject: MaybeIRModel): boolean { - const dereferenced = this.input.schema(schemaObject) + const dereferenced = isRef(schemaObject) + ? this.input.schema(schemaObject) + : schemaObject return ( dereferenced.type === "object" && diff --git a/packages/openapi-code-generator/src/typescript/common/typescript-common.ts b/packages/openapi-code-generator/src/typescript/common/typescript-common.ts index b7dd8083..b390a310 100644 --- a/packages/openapi-code-generator/src/typescript/common/typescript-common.ts +++ b/packages/openapi-code-generator/src/typescript/common/typescript-common.ts @@ -277,7 +277,12 @@ export function requestBodyAsParameter( in: "body", required: requestBody.required, explode: undefined, - schema: {type: "never", nullable: false, readOnly: false}, + schema: { + isIRModel: true, + type: "never", + nullable: false, + readOnly: false, + }, deprecated: false, }, serializer: undefined, diff --git a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts index d41d4382..a72fb225 100644 --- a/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/server-operation-builder.ts @@ -1,12 +1,7 @@ import type {Input} from "../../core/input" import {logger} from "../../core/logger" -import type { - IRModelObject, - IROperation, - IRParameter, -} from "../../core/openapi-types-normalized" +import type {IROperation} from "../../core/openapi-types-normalized" import {extractPlaceholders} from "../../core/openapi-utils" -import {upperFirst} from "../../core/utils" import type {SchemaBuilder} from "../common/schema-builders/schema-builder" import type {TypeBuilder} from "../common/type-builder" import {intersect, object} from "../common/type-utils" @@ -19,40 +14,10 @@ export type ServerSymbols = { implPropName: string implTypeName: string responderName: string - paramSchema: string - querySchema: string requestBodySchema: string - requestHeaderSchema: string responseBodyValidator: string } -export function reduceParamsToOpenApiSchema( - parameters: IRParameter[], -): IRModelObject { - return parameters.reduce( - (model, parameter) => { - model.properties[parameter.name] = parameter.schema - - if (parameter.required) { - model.required.push(parameter.name) - } - - return model - }, - { - type: "object", - properties: {}, - required: [], - oneOf: [], - allOf: [], - anyOf: [], - additionalProperties: false, - nullable: false, - readOnly: false, - } as IRModelObject, - ) -} - export type ServerOperationResponseSchemas = { specific: { statusString: string @@ -72,14 +37,17 @@ export type ServerOperationResponseSchemas = { export type Parameters = { type: string path: { + name: string schema: string | undefined type: string } query: { + name: string schema: string | undefined type: string } header: { + name: string schema: string | undefined type: string } @@ -106,7 +74,7 @@ export class ServerOperationBuilder { } get route(): string { - const {route, parameters} = this.operation + const {route, params} = this.operation const placeholders = extractPlaceholders(route) @@ -117,9 +85,7 @@ export class ServerOperationBuilder { ) } - const parameter = parameters.find( - (it) => it.name === placeholder && it.in === "path", - ) + const parameter = params.path.list.find((it) => it.name === placeholder) if (!parameter) { throw new Error( @@ -136,10 +102,10 @@ export class ServerOperationBuilder { } parameters(symbols: ServerSymbols): Parameters { - const path = this.pathParameters(symbols.paramSchema) - const query = this.queryParameters(symbols.querySchema) - const header = this.headerParameters(symbols.requestHeaderSchema) - const body = this.requestBodyParameter(symbols.requestBodySchema) + const path = this.pathParameters() + const query = this.queryParameters() + const header = this.headerParameters() + const body = this.requestBodyParameter() const type = `Params< ${path.type}, @@ -195,79 +161,67 @@ export class ServerOperationBuilder { return {implementation, type} } - private pathParameters(schemaSymbolName: string): Parameters["path"] { - const parameters = this.operation.parameters.filter( - (it) => it.in === "path", - ) + private pathParameters(): Parameters["path"] { + const hasParameters = this.operation.params.path.list.length - const schema = parameters.length - ? this.schemaBuilder.fromParameters(parameters) + const schema = hasParameters + ? this.schemaBuilder.fromModel( + this.input.schema(this.operation.params.path.$ref), + true, + true, + ) : undefined let type = "void" if (schema) { - type = this.types.schemaObjectToType( - this.input.loader.addVirtualType( - this.operationId, - upperFirst(schemaSymbolName), - reduceParamsToOpenApiSchema(parameters), - ), - ) + type = this.types.schemaObjectToType(this.operation.params.path.$ref) } - return {schema: schema, type} + return {name: this.operation.params.path.name, schema: schema, type} } - private queryParameters(schemaSymbolName: string): Parameters["query"] { - const parameters = this.operation.parameters.filter( - (it) => it.in === "query", - ) + private queryParameters(): Parameters["query"] { + const hasParameters = this.operation.params.query.list.length - const schema = parameters.length - ? this.schemaBuilder.fromParameters(parameters) + const schema = hasParameters + ? this.schemaBuilder.fromModel( + this.input.schema(this.operation.params.query.$ref), + true, + true, + ) : undefined let type = "void" if (schema) { - type = this.types.schemaObjectToType( - this.input.loader.addVirtualType( - this.operationId, - upperFirst(schemaSymbolName), - reduceParamsToOpenApiSchema(parameters), - ), - ) + type = this.types.schemaObjectToType(this.operation.params.query.$ref) } - return {schema: schema, type} + return {name: this.operation.params.query.name, schema: schema, type} } - private headerParameters(schemaSymbolName: string): Parameters["header"] { - const parameters = this.operation.parameters - .filter((it) => it.in === "header") - .map((it) => ({...it, name: it.name.toLowerCase()})) + private headerParameters(): Parameters["header"] { + const hasParameters = this.operation.params.header.list.length - const schema = parameters.length - ? this.schemaBuilder.fromParameters(parameters) + const schema = hasParameters + ? this.schemaBuilder.fromModel( + this.input.schema(this.operation.params.header.$ref), + true, + true, + ) : undefined let type = "void" if (schema) { - type = this.types.schemaObjectToType( - this.input.loader.addVirtualType( - this.operationId, - upperFirst(schemaSymbolName), - reduceParamsToOpenApiSchema(parameters), - ), - ) + type = this.types.schemaObjectToType(this.operation.params.header.$ref) } - return {schema: schema, type} + return {name: this.operation.params.header.name, schema: schema, type} } - private requestBodyParameter(schemaSymbolName: string): Parameters["body"] { + private requestBodyParameter(): Parameters["body"] { const requestBody = requestBodyAsParameter( this.operation, this.config.requestBody.supportedMediaTypes, @@ -301,14 +255,9 @@ export class ServerOperationBuilder { let type = "void" + // todo: we create a duplicate type even when the schema is a ref. if (schema && requestBody?.parameter) { - type = this.types.schemaObjectToType( - this.input.loader.addVirtualType( - this.operationId, - upperFirst(schemaSymbolName), - this.input.schema(requestBody.parameter.schema), - ), - ) + type = this.types.schemaObjectToType(requestBody.parameter.schema) } return { diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts index 6c8be9a5..7b479207 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-express/typescript-express-router-builder.ts @@ -92,16 +92,17 @@ export class ExpressRouterBuilder extends AbstractRouterBuilder { const params = builder.parameters(symbols) if (params.path.schema) { - statements.push(constStatement(symbols.paramSchema, params.path.schema)) + statements.push(constStatement(params.path.name, params.path.schema)) } + if (params.query.schema) { - statements.push(constStatement(symbols.querySchema, params.query.schema)) + statements.push(constStatement(params.query.name, params.query.schema)) } + if (params.header.schema) { - statements.push( - constStatement(symbols.requestHeaderSchema, params.header.schema), - ) + statements.push(constStatement(params.header.name, params.header.schema)) } + if (params.body.schema) { if (!params.body.isSupported) { statements.push( @@ -148,10 +149,10 @@ const ${symbols.responseBodyValidator} = ${builder.responseValidator()} router.${builder.method.toLowerCase()}(\`${builder.route}\`, async (req: Request, res: Response, next: NextFunction) => { try { const input = { - params: ${params.path.schema ? `parseRequestInput(${symbols.paramSchema}, req.params, RequestInputType.RouteParam)` : "undefined"}, - query: ${params.query.schema ? `parseRequestInput(${symbols.querySchema}, req.query, RequestInputType.QueryString)` : "undefined"}, + params: ${params.path.schema ? `parseRequestInput(${params.path.name}, req.params, RequestInputType.RouteParam)` : "undefined"}, + query: ${params.query.schema ? `parseRequestInput(${params.query.name}, req.query, RequestInputType.QueryString)` : "undefined"}, body: ${params.body.schema ? `parseRequestInput(${symbols.requestBodySchema}, req.body, RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}` : "undefined"}, - headers: ${params.header.schema ? `parseRequestInput(${symbols.requestHeaderSchema}, req.headers, RequestInputType.RequestHeader)` : "undefined"} + headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, req.headers, RequestInputType.RequestHeader)` : "undefined"} } const responder = ${responder.implementation} @@ -251,10 +252,7 @@ export ${this.implementationMethod === "type" || this.implementationMethod === " implPropName: operationId, implTypeName: titleCase(operationId), responderName: `${titleCase(operationId)}Responder`, - paramSchema: `${operationId}ParamSchema`, - querySchema: `${operationId}QuerySchema`, requestBodySchema: `${operationId}RequestBody`, - requestHeaderSchema: `${operationId}RequestHeaderSchema`, responseBodyValidator: `${operationId}ResponseBodyValidator`, } } diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts index d1a88b02..b79d5aab 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.spec.ts @@ -83,7 +83,12 @@ describe("typescript/server/typescript-koa/koa-router-builder", () => { serverRouterBuilder.add({ method: "GET", - parameters: [], + params: { + all: [], + path: {list: [], name: "", $ref: {$ref: ""}}, + query: {list: [], name: "", $ref: {$ref: ""}}, + header: {list: [], name: "", $ref: {$ref: ""}}, + }, servers: [], operationId: "testOperation", deprecated: false, @@ -96,7 +101,12 @@ describe("typescript/server/typescript-koa/koa-router-builder", () => { }) serverRouterBuilder.add({ method: "GET", - parameters: [], + params: { + all: [], + path: {list: [], name: "", $ref: {$ref: ""}}, + query: {list: [], name: "", $ref: {$ref: ""}}, + header: {list: [], name: "", $ref: {$ref: ""}}, + }, servers: [], operationId: "anotherTestOperation", deprecated: false, diff --git a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts index e964a343..444eac72 100644 --- a/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts +++ b/packages/openapi-code-generator/src/typescript/server/typescript-koa/typescript-koa-router-builder.ts @@ -89,16 +89,18 @@ export class KoaRouterBuilder extends AbstractRouterBuilder { const params = builder.parameters(symbols) if (params.path.schema) { - statements.push(constStatement(symbols.paramSchema, params.path.schema)) + statements.push(constStatement(params.path.name, params.path.schema)) } + if (params.query.schema) { - statements.push(constStatement(symbols.querySchema, params.query.schema)) + statements.push(constStatement(params.query.name, params.query.schema)) } + if (params.header.schema) { - statements.push( - constStatement(symbols.requestHeaderSchema, params.header.schema), - ) + statements.push(constStatement(params.header.name, params.header.schema)) } + + // todo: inline, currently needless variable. if (params.body.schema) { if (!params.body.isSupported) { statements.push( @@ -152,10 +154,10 @@ const ${symbols.responseBodyValidator} = ${builder.responseValidator()} router.${builder.method.toLowerCase()}('${symbols.implPropName}','${builder.route}', async (ctx, next) => { const input = { - params: ${params.path.schema ? `parseRequestInput(${symbols.paramSchema}, ctx.params, RequestInputType.RouteParam)` : "undefined"}, - query: ${params.query.schema ? `parseRequestInput(${symbols.querySchema}, ctx.query, RequestInputType.QueryString)` : "undefined"}, + params: ${params.path.schema ? `parseRequestInput(${params.path.name}, ctx.params, RequestInputType.RouteParam)` : "undefined"}, + query: ${params.query.schema ? `parseRequestInput(${params.query.name}, ctx.query, RequestInputType.QueryString)` : "undefined"}, body: ${params.body.schema ? `parseRequestInput(${symbols.requestBodySchema}, Reflect.get(ctx.request, "body"), RequestInputType.RequestBody)${!params.body.isSupported ? " as never" : ""}` : "undefined"}, - headers: ${params.header.schema ? `parseRequestInput(${symbols.requestHeaderSchema}, Reflect.get(ctx.request, "headers"), RequestInputType.RequestHeader)` : "undefined"} + headers: ${params.header.schema ? `parseRequestInput(${params.header.name}, Reflect.get(ctx.request, "headers"), RequestInputType.RequestHeader)` : "undefined"} } const responder = ${responder.implementation} @@ -183,10 +185,7 @@ router.${builder.method.toLowerCase()}('${symbols.implPropName}','${builder.rout implPropName: operationId, implTypeName: titleCase(operationId), responderName: `${titleCase(operationId)}Responder`, - paramSchema: `${operationId}ParamSchema`, - querySchema: `${operationId}QuerySchema`, requestBodySchema: `${operationId}RequestBody`, - requestHeaderSchema: `${operationId}HeaderSchema`, responseBodyValidator: `${operationId}ResponseValidator`, } }