From 2460c375e637ab15c2198654178ac9406ec48dc8 Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:50:17 +0900 Subject: [PATCH 1/4] feat: support OpenAPI 3.x specifications --- README.md | 2 +- docs/ja/README-ja.md | 2 +- .../_shared/ApiClientInterface.ts | 2 +- .../_shared/MethodBody/CallRequest.ts | 8 +- .../_shared/MethodBody/index.ts | 3 +- src/generateValidRootSchema.ts | 48 +++- src/index.ts | 2 +- src/internal/OpenApiTools/Extractor.ts | 3 +- src/internal/OpenApiTools/Guard.ts | 6 +- src/internal/OpenApiTools/InferredType.ts | 25 +- src/internal/OpenApiTools/Name.ts | 1 + src/internal/OpenApiTools/Parser.ts | 26 ++ .../OpenApiTools/Walker/Definition.ts | 11 +- src/internal/OpenApiTools/Walker/Operation.ts | 15 +- src/internal/OpenApiTools/Walker/Store.ts | 12 +- .../OpenApiTools/components/Header.ts | 4 +- .../OpenApiTools/components/MediaType.ts | 43 ++- .../OpenApiTools/components/MediaTypes.ts | 61 ++++ .../OpenApiTools/components/Parameter.ts | 45 ++- .../OpenApiTools/components/Parameters.ts | 2 +- .../OpenApiTools/components/PathItem.ts | 64 +++++ .../OpenApiTools/components/PathItems.ts | 7 +- .../OpenApiTools/components/Reference.ts | 5 +- .../OpenApiTools/components/Schema.ts | 131 +++++++-- .../OpenApiTools/components/Schemas.ts | 48 +++- .../OpenApiTools/components/SecuritySchema.ts | 46 ++- src/internal/OpenApiTools/toTypeNode.ts | 202 ++++++++++++-- src/internal/OpenApiTools/types/index.ts | 11 +- src/internal/ResolveReference/index.ts | 42 ++- src/internal/TsGenerator/factory.ts | 5 +- .../Validator/__tests__/openapi-3x.test.ts | 262 ++++++++++++++++++ src/internal/Validator/openapi.json | 151 +++++++++- src/typedef/CodeGenerator.ts | 2 +- src/typedef/OpenApi.ts | 177 ++++++++++-- 34 files changed, 1298 insertions(+), 176 deletions(-) create mode 100644 src/internal/OpenApiTools/components/MediaTypes.ts create mode 100644 src/internal/Validator/__tests__/openapi-3x.test.ts diff --git a/README.md b/README.md index 93a93a82..4526d9df 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [日本語](./docs/ja/README-ja.md) -This library provides TypeScript type definitions and extracted parameters from OpenAPI v3.0.x compliant specifications. +This library provides TypeScript type definitions and extracted parameters from OpenAPI v3.x compliant specifications. Template literals are used to generate the code, which is accurately converted to TypeScript code. Since the parameters extracted from OpenAPI can be used freely, it can be used for automatic generation of API Client and Server Side code, load balancer configuration files, etc. diff --git a/docs/ja/README-ja.md b/docs/ja/README-ja.md index 08f75d90..fcad6c31 100644 --- a/docs/ja/README-ja.md +++ b/docs/ja/README-ja.md @@ -1,6 +1,6 @@ # @himenon/openapi-typescript-code-generator -このライブラリは OpenAPI v3.0.x 系に準拠した仕様書から TypeScript の型定義と抽出したパラメーターを提供します。 +このライブラリは OpenAPI v3.x 系に準拠した仕様書から TypeScript の型定義と抽出したパラメーターを提供します。 コードの生成にはテンプレートリテラルを利用し、正確に TypeScript のコードへ変換します。 OpenAPI から抽出したパラメーターは自由に使うことができるため、API Client や Server Side 用のコード、ロードバランサーの設定ファイルなどの自動生成に役立てることができます。 diff --git a/src/code-templates/_shared/ApiClientInterface.ts b/src/code-templates/_shared/ApiClientInterface.ts index f243596e..50bea812 100644 --- a/src/code-templates/_shared/ApiClientInterface.ts +++ b/src/code-templates/_shared/ApiClientInterface.ts @@ -3,7 +3,7 @@ import type { CodeGenerator } from "../../types"; import type { MethodType } from "./MethodBody/types"; import type { Option } from "./types"; -const httpMethodList: string[] = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]; +const httpMethodList: string[] = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE", "QUERY"]; const createErrorResponsesTypeAlias = (typeName: string, factory: TsGenerator.Factory.Type, errorResponseNames: string[]) => { if (errorResponseNames.length === 0) { diff --git a/src/code-templates/_shared/MethodBody/CallRequest.ts b/src/code-templates/_shared/MethodBody/CallRequest.ts index 3f394254..2fd0ea91 100644 --- a/src/code-templates/_shared/MethodBody/CallRequest.ts +++ b/src/code-templates/_shared/MethodBody/CallRequest.ts @@ -4,6 +4,8 @@ import * as Utils from "../utils"; import { createEncodingMap } from "./createEncodingMap"; import type { MethodType } from "./types"; +const standardHttpMethods = new Set(["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE", "QUERY"]); + export interface Params { httpMethod: string; hasRequestBody: boolean; @@ -43,12 +45,16 @@ export const create = (factory: TsGenerator.Factory.Type, params: CodeGenerator. }; const expression = Utils.generateVariableIdentifier(factory, apiClientVariableIdentifier[methodType]); const requestBodyEncoding = createEncodingParams(factory, params); + const httpMethod = params.operationParams.httpMethod.toUpperCase(); + const httpMethodLiteral = factory.StringLiteral.create({ text: httpMethod }); + // OpenAPI 3.2 の additionalOperations は任意の HTTP メソッド名を許可します。 + const httpMethodInitializer = standardHttpMethods.has(httpMethod) ? httpMethodLiteral : `${httpMethodLiteral} as HttpMethod`; const requestArgs = factory.ObjectLiteralExpression.create({ properties: [ factory.PropertyAssignment.create({ name: "httpMethod", - initializer: factory.StringLiteral.create({ text: params.operationParams.httpMethod.toUpperCase() }), + initializer: httpMethodInitializer, }), factory.ShorthandPropertyAssignment.create({ name: methodType === "currying-function" ? "uri" : "url", diff --git a/src/code-templates/_shared/MethodBody/index.ts b/src/code-templates/_shared/MethodBody/index.ts index b1fb347a..37fb6534 100644 --- a/src/code-templates/_shared/MethodBody/index.ts +++ b/src/code-templates/_shared/MethodBody/index.ts @@ -95,7 +95,8 @@ export const create = (factory: TsGenerator.Factory.Type, params: CodeGenerator. // Generate Query Parameter if (convertedParams.hasQueryParameters) { - const queryParameter = pickedParameters.filter(item => item.in === "query"); + // OpenAPI 3.2 の querystring パラメータもクエリ文字列へ渡します。 + const queryParameter = pickedParameters.filter(item => item.in === "query" || item.in === "querystring"); const queryObject = Object.values(queryParameter).reduce<{ [key: string]: QueryParameter.Item }>((previous, current) => { const { text, escaped } = escapeText(current.name); const variableDeclareText = escaped ? `params.parameter[${text}]` : `params.parameter.${text}`; diff --git a/src/generateValidRootSchema.ts b/src/generateValidRootSchema.ts index 45b27bc3..608462fd 100644 --- a/src/generateValidRootSchema.ts +++ b/src/generateValidRootSchema.ts @@ -1,11 +1,11 @@ import type * as Types from "./types"; export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.OpenApi.Document => { - if (!input.paths) { - return input; - } /** update undefined operation id */ for (const [path, methods] of Object.entries(input.paths || {})) { + if ("$ref" in methods) { + continue; + } const targets = { get: methods.get, put: methods.put, @@ -15,19 +15,39 @@ export const generateValidRootSchema = (input: Types.OpenApi.Document): Types.Op head: methods.head, patch: methods.patch, trace: methods.trace, + query: methods.query, } satisfies Record; - for (const [method, operation] of Object.entries(targets)) { - if (!operation) { - continue; - } - // skip reference object - if ("$ref" in operation) { - continue; - } - if (!operation.operationId) { - operation.operationId = `${method.toLowerCase()}${path.charAt(0).toUpperCase() + path.slice(1)}`; - } + assignOperationIds(path, targets); + // OpenAPI 3.2 で追加された additionalOperations にも operationId を補完します。 + assignOperationIds(path, methods.additionalOperations || {}); + } + for (const [name, pathItem] of Object.entries(input.components?.pathItems || {})) { + if ("$ref" in pathItem) { + continue; } + const targets = { + get: pathItem.get, + put: pathItem.put, + post: pathItem.post, + delete: pathItem.delete, + options: pathItem.options, + head: pathItem.head, + patch: pathItem.patch, + trace: pathItem.trace, + query: pathItem.query, + } satisfies Record; + assignOperationIds(name, targets); + // OpenAPI 3.2 で追加された additionalOperations にも operationId を補完します。 + assignOperationIds(name, pathItem.additionalOperations || {}); } return input; }; + +const assignOperationIds = (path: string, operations: Record): void => { + for (const [method, operation] of Object.entries(operations)) { + if (!operation || "$ref" in operation || operation.operationId) { + continue; + } + operation.operationId = `${method.toLowerCase()}${path.charAt(0).toUpperCase() + path.slice(1)}`; + } +}; diff --git a/src/index.ts b/src/index.ts index b7beeb78..48c7cf55 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ export class CodeGenerator { JSON.parse(JSON.stringify(this.rootSchema)), ); } else { - this.rootSchema = entryPointOrDocument; + this.rootSchema = generateValidRootSchema(entryPointOrDocument); this.resolvedReferenceDocument = Api.ResolveReference.resolve(".", ".", JSON.parse(JSON.stringify(this.rootSchema))); } this.parser = this.createParser(); diff --git a/src/internal/OpenApiTools/Extractor.ts b/src/internal/OpenApiTools/Extractor.ts index f0447a41..da8bfb77 100644 --- a/src/internal/OpenApiTools/Extractor.ts +++ b/src/internal/OpenApiTools/Extractor.ts @@ -69,7 +69,8 @@ const hasQueryParameters = (parameters?: OpenApi.Parameter[]): boolean => { if (!parameters) { return false; } - return parameters.filter(parameter => parameter.in === "query").length > 0; + // OpenAPI 3.2 で追加された querystring もクエリ文字列として扱います。 + return parameters.some(parameter => parameter.in === "query" || parameter.in === "querystring"); }; export const generateCodeGeneratorParamsArray = ( diff --git a/src/internal/OpenApiTools/Guard.ts b/src/internal/OpenApiTools/Guard.ts index 54f85df8..1c96e6dd 100644 --- a/src/internal/OpenApiTools/Guard.ts +++ b/src/internal/OpenApiTools/Guard.ts @@ -9,10 +9,12 @@ export const isReference = (data: any): data is OpenApi.Reference => { return typeof data.$ref === "string"; }; -export const isUnSupportSchema = (schema: OpenApi.Schema): schema is Types.UnSupportSchema => { +export const isTypeArraySchema = (schema: OpenApi.Schema): schema is Types.TypeArraySchema => { return Array.isArray(schema.type); }; +export const isUnSupportSchema = isTypeArraySchema; + export const isObjectSchema = (schema: OpenApi.Schema): schema is Types.ObjectSchema => { return schema.type === "object"; }; @@ -67,5 +69,5 @@ export const isAnyOfSchema = (schema: OpenApi.Schema): schema is Types.AnyOfSche }; export const isComponentName = (name: string): name is Def.ComponentName => { - return ["schemas", "headers", "responses", "parameters", "requestBodies", "securitySchemes", "pathItems"].includes(name); + return ["schemas", "headers", "responses", "parameters", "requestBodies", "securitySchemes", "pathItems", "mediaTypes"].includes(name); }; diff --git a/src/internal/OpenApiTools/InferredType.ts b/src/internal/OpenApiTools/InferredType.ts index 339cc276..65bb42f8 100644 --- a/src/internal/OpenApiTools/InferredType.ts +++ b/src/internal/OpenApiTools/InferredType.ts @@ -4,13 +4,36 @@ export const getInferredType = (schema: OpenApi.Schema): OpenApi.Schema | undefi if (schema.type || schema.oneOf || schema.allOf || schema.anyOf) { return schema; } + // OpenAPI 3.1 で JSON Schema の const キーワードが利用可能になりました。 + if (Object.hasOwn(schema, "const")) { + const value = schema.const; + const type = value === null ? "null" : typeof value; + if (type === "string" || type === "number" || type === "boolean" || type === "null") { + return { ...schema, type } as OpenApi.Schema; + } + } // type: arrayを指定せずに、itemsのみを指定している場合に type array変換する if (schema.items) { return { ...schema, type: "array" }; } // type: string/numberを指定せずに、enumのみを指定している場合に type array変換する if (schema.enum) { - return { ...schema, type: "string" }; + const enumTypes = [ + ...new Set( + schema.enum + .map(value => { + if (value === null) return "null"; + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return typeof value; + return undefined; + }) + .filter((type): type is "string" | "number" | "boolean" | "null" => !!type), + ), + ]; + // OpenAPI 3.1 では enum の値から複数の JSON Schema 型を推論できます。 + if (enumTypes.length > 1) { + return { ...schema, type: enumTypes }; + } + return { ...schema, type: enumTypes[0] || "string" }; } // type: objectを指定せずに、propertiesのみを指定している場合に type object変換する if (schema.properties) { diff --git a/src/internal/OpenApiTools/Name.ts b/src/internal/OpenApiTools/Name.ts index 0eabc04c..414998b3 100644 --- a/src/internal/OpenApiTools/Name.ts +++ b/src/internal/OpenApiTools/Name.ts @@ -6,6 +6,7 @@ export const Components = { PathItems: "PathItems", RequestBodies: "RequestBodies", Responses: "Responses", + MediaTypes: "MediaTypes", } as const; export const ComponentChild = { diff --git a/src/internal/OpenApiTools/Parser.ts b/src/internal/OpenApiTools/Parser.ts index 2240a773..ae19f446 100644 --- a/src/internal/OpenApiTools/Parser.ts +++ b/src/internal/OpenApiTools/Parser.ts @@ -2,7 +2,9 @@ import type { CodeGenerator, OpenApi } from "../../types"; import * as TypeScriptCodeGenerator from "../TsGenerator"; import * as ConvertContext from "./ConverterContext"; import * as Headers from "./components/Headers"; +import * as MediaTypes from "./components/MediaTypes"; import * as Parameters from "./components/Parameters"; +import * as PathItems from "./components/PathItems"; import * as RequestBodies from "./components/RequestBodies"; import * as Responses from "./components/Responses"; import * as Schemas from "./components/Schemas"; @@ -46,6 +48,18 @@ export class Parser { this.convertContext, ); } + if (rootSchema.components.mediaTypes) { + // OpenAPI 3.2 で再利用可能な Media Type が追加されました。 + MediaTypes.generateNamespace( + this.entryPoint, + this.currentPoint, + this.store, + this.factory, + rootSchema.components.mediaTypes, + toTypeNodeContext, + this.convertContext, + ); + } if (rootSchema.components.headers) { Headers.generateNamespace( this.entryPoint, @@ -90,6 +104,18 @@ export class Parser { this.convertContext, ); } + if (rootSchema.components.pathItems) { + // OpenAPI 3.1 で components.pathItems に再利用可能な Path Item が追加されました。 + PathItems.generateNamespace( + this.entryPoint, + this.currentPoint, + this.store, + this.factory, + rootSchema.components.pathItems, + toTypeNodeContext, + this.convertContext, + ); + } } if (rootSchema.paths) { Paths.generateStatements( diff --git a/src/internal/OpenApiTools/Walker/Definition.ts b/src/internal/OpenApiTools/Walker/Definition.ts index c5ce0bbd..1e376948 100644 --- a/src/internal/OpenApiTools/Walker/Definition.ts +++ b/src/internal/OpenApiTools/Walker/Definition.ts @@ -1,4 +1,12 @@ -export type ComponentName = "schemas" | "headers" | "responses" | "parameters" | "requestBodies" | "securitySchemes" | "pathItems"; +export type ComponentName = + | "schemas" + | "headers" + | "responses" + | "parameters" + | "requestBodies" + | "securitySchemes" + | "pathItems" + | "mediaTypes"; export const componentNames: ComponentName[] = [ "schemas", @@ -8,4 +16,5 @@ export const componentNames: ComponentName[] = [ "requestBodies", "securitySchemes", "pathItems", + "mediaTypes", ]; diff --git a/src/internal/OpenApiTools/Walker/Operation.ts b/src/internal/OpenApiTools/Walker/Operation.ts index 7a2694d6..82a88396 100644 --- a/src/internal/OpenApiTools/Walker/Operation.ts +++ b/src/internal/OpenApiTools/Walker/Operation.ts @@ -1,6 +1,6 @@ import type { CodeGenerator, OpenApi } from "../../../types"; -const httpMethodList = ["get", "put", "post", "delete", "options", "head", "patch", "trace"] as const; +const httpMethodList = ["get", "put", "post", "delete", "options", "head", "patch", "trace", "query"] as const; export interface State { [operationId: string]: CodeGenerator.OpenApiOperation; @@ -19,15 +19,22 @@ export const create = (rootSchema: OpenApi.Document): State => { const paths = rootSchema.paths || {}; const state: State = {}; Object.entries(paths).forEach(([requestUri, pathItem]) => { - httpMethodList.forEach(httpMethod => { - const operation = pathItem[httpMethod]; + const pathItemData = pathItem as OpenApi.PathItem; + if (pathItemData.$ref) { + return; + } + const operations: Record = { + ...Object.fromEntries(httpMethodList.map(httpMethod => [httpMethod, pathItemData[httpMethod]])), + ...pathItemData.additionalOperations, + }; + Object.entries(operations).forEach(([httpMethod, operation]) => { if (!operation) { return; } if (!operation.operationId) { return; } - const parameters = [...(pathItem.parameters || []), ...(operation.parameters || [])] as OpenApi.Parameter[]; + const parameters = [...(pathItemData.parameters || []), ...(operation.parameters || [])] as OpenApi.Parameter[]; const requestBody = operation.requestBody as OpenApi.RequestBody | undefined; const hasValidMediaType = Object.values(requestBody?.content || {}).filter(mediaType => Object.values(mediaType).length > 0).length > 0; diff --git a/src/internal/OpenApiTools/Walker/Store.ts b/src/internal/OpenApiTools/Walker/Store.ts index c6652e72..4d6900fc 100644 --- a/src/internal/OpenApiTools/Walker/Store.ts +++ b/src/internal/OpenApiTools/Walker/Store.ts @@ -134,7 +134,7 @@ class Store { this.state.additionalStatements = this.state.additionalStatements.concat(statements); } public getPathItem(localPath: string): OpenApi.PathItem { - if (!localPath.startsWith("components/pathItem")) { + if (!localPath.startsWith("components/pathItems")) { throw new Error(`Only use start with 'component/pathItems': ${localPath}`); } const result = DotProp.getProperty(this.state.document, localPath.replace(/\//g, ".")); @@ -153,6 +153,16 @@ class Store { } return result as OpenApi.Parameter; } + public getMediaType(localPath: string): OpenApi.MediaType | OpenApi.Reference { + if (!localPath.startsWith("components/mediaTypes")) { + throw new Error(`Only use start with 'components/mediaTypes': ${localPath}`); + } + const result = DotProp.getProperty(this.state.document, localPath.replace(/\//g, ".")); + if (!result) { + throw new Error(`Not found ${localPath}`); + } + return result as OpenApi.MediaType | OpenApi.Reference; + } public isAfterDefined(referencePath: string): boolean { return !!DotProp.getProperty(this.state.document, referencePath.replace(/\//g, ".")); } diff --git a/src/internal/OpenApiTools/components/Header.ts b/src/internal/OpenApiTools/components/Header.ts index ef58c953..4fb7caba 100644 --- a/src/internal/OpenApiTools/components/Header.ts +++ b/src/internal/OpenApiTools/components/Header.ts @@ -17,7 +17,7 @@ export const generateTypeNode = ( return factory.TypeAliasDeclaration.create({ export: true, name: converterContext.escapeDeclarationText(name), - type: ToTypeNode.convert(entryPoint, currentPoint, factory, header.schema || { type: "null" }, context, converterContext), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, header.schema ?? { type: "null" }, context, converterContext), }); }; @@ -56,7 +56,7 @@ export const generatePropertySignature = ( readOnly: false, name: converterContext.escapePropertySignatureName(name), optional: false, - type: ToTypeNode.convert(entryPoint, currentPoint, factory, header.schema || { type: "null" }, context, converterContext), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, header.schema ?? { type: "null" }, context, converterContext), }); }; diff --git a/src/internal/OpenApiTools/components/MediaType.ts b/src/internal/OpenApiTools/components/MediaType.ts index 5d24d7ed..b23456db 100644 --- a/src/internal/OpenApiTools/components/MediaType.ts +++ b/src/internal/OpenApiTools/components/MediaType.ts @@ -1,14 +1,16 @@ import type { OpenApi } from "../../../types"; import type { Factory } from "../../TsGenerator"; import type * as ConverterContext from "../ConverterContext"; +import * as Guard from "../Guard"; import * as ToTypeNode from "../toTypeNode"; +import * as Reference from "./Reference"; export const generatePropertySignature = ( entryPoint: string, currentPoint: string, factory: Factory.Type, protocol: string, - schema: OpenApi.Schema, + schema: OpenApi.JSONSchemaDefinition | OpenApi.Reference, context: ToTypeNode.Context, converterContext: ConverterContext.Types, ): string => { @@ -16,8 +18,16 @@ export const generatePropertySignature = ( readOnly: false, name: converterContext.escapePropertySignatureName(protocol), optional: false, - type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, converterContext), - comment: schema.description, + type: ToTypeNode.convert( + entryPoint, + currentPoint, + factory, + schema, + context, + converterContext, + typeof schema === "object" && !Guard.isReference(schema) ? { schemaRoot: schema } : undefined, + ), + comment: !Guard.isReference(schema) && typeof schema !== "boolean" ? schema.description : undefined, }); }; @@ -25,15 +35,34 @@ export const generatePropertySignatures = ( entryPoint: string, currentPoint: string, factory: Factory.Type, - content: Record, + content: Record, context: ToTypeNode.Context, converterContext: ConverterContext.Types, ): string[] => { return Object.entries(content).reduce((previous, [protocol, mediaType]) => { - if (!mediaType.schema) { + if (Guard.isReference(mediaType)) { + const reference = Reference.generate(entryPoint, currentPoint, mediaType); + if (reference.type === "local") { + // OpenAPI 3.2 で追加された components.mediaTypes の参照を型参照として出力します。 + return previous.concat( + factory.PropertySignature.create({ + readOnly: false, + name: converterContext.escapePropertySignatureName(protocol), + optional: false, + type: factory.TypeReferenceNode.create({ + name: context.resolveReferencePath(currentPoint, reference.path).name, + }), + }), + ); + } + mediaType = reference.data; + } + // OpenAPI 3.2 で追加された itemSchema は、ストリーミング形式の各 item の型を表します。 + const schema = mediaType.schema ?? mediaType.itemSchema; + if (schema === undefined) { return previous; } - return previous.concat(generatePropertySignature(entryPoint, currentPoint, factory, protocol, mediaType.schema, context, converterContext)); + return previous.concat(generatePropertySignature(entryPoint, currentPoint, factory, protocol, schema, context, converterContext)); }, []); }; @@ -42,7 +71,7 @@ export const generateInterface = ( currentPoint: string, factory: Factory.Type, name: string, - content: Record, + content: Record, context: ToTypeNode.Context, converterContext: ConverterContext.Types, ): string => { diff --git a/src/internal/OpenApiTools/components/MediaTypes.ts b/src/internal/OpenApiTools/components/MediaTypes.ts new file mode 100644 index 00000000..0e426aa1 --- /dev/null +++ b/src/internal/OpenApiTools/components/MediaTypes.ts @@ -0,0 +1,61 @@ +import type { OpenApi } from "../../../types"; +import type { Factory } from "../../TsGenerator"; +import type * as ConverterContext from "../ConverterContext"; +import * as Guard from "../Guard"; +import * as Name from "../Name"; +import * as ToTypeNode from "../toTypeNode"; +import type * as Walker from "../Walker"; +import * as Reference from "./Reference"; + +export const generateNamespace = ( + entryPoint: string, + currentPoint: string, + store: Walker.Store, + factory: Factory.Type, + mediaTypes: Record, + context: ToTypeNode.Context, + convertContext: ConverterContext.Types, +): void => { + const basePath = "components/mediaTypes"; + store.addComponent("mediaTypes", { + kind: "namespace", + name: Name.Components.MediaTypes, + }); + + Object.entries(mediaTypes).forEach(([name, mediaType]) => { + let targetMediaType: OpenApi.MediaType | undefined; + if (Guard.isReference(mediaType)) { + const reference = Reference.generate(entryPoint, currentPoint, mediaType); + if (reference.type === "local") { + const resolved = store.getMediaType(reference.path); + if (!Guard.isReference(resolved) && typeof resolved !== "boolean") { + targetMediaType = resolved as OpenApi.MediaType; + } + } else { + targetMediaType = reference.data; + } + } else { + targetMediaType = mediaType; + } + if (!targetMediaType) { + return; + } + + // OpenAPI 3.2 で再利用可能な Media Type が追加されました。 + const schema = targetMediaType.schema ?? targetMediaType.itemSchema; + const type = + schema !== undefined + ? ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext) + : factory.TypeNode.create({ type: "any" }); + store.addStatement(`${basePath}/${name}`, { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(name), + value: factory.TypeAliasDeclaration.create({ + export: true, + name: convertContext.escapeDeclarationText(name), + type, + comment: targetMediaType.description, + }), + }); + }); +}; diff --git a/src/internal/OpenApiTools/components/Parameter.ts b/src/internal/OpenApiTools/components/Parameter.ts index c445de7e..ae20b8fd 100644 --- a/src/internal/OpenApiTools/components/Parameter.ts +++ b/src/internal/OpenApiTools/components/Parameter.ts @@ -6,6 +6,38 @@ import * as ToTypeNode from "../toTypeNode"; import type * as Walker from "../Walker"; import * as Reference from "./Reference"; +const getParameterSchema = (parameter: OpenApi.Parameter): OpenApi.JSONSchemaDefinition | OpenApi.Reference | undefined => { + if (parameter.schema !== undefined) { + return parameter.schema; + } + // OpenAPI 3.2 で querystring パラメータの content によるスキーマ指定を扱います。 + const mediaType = Object.values(parameter.content || {})[0]; + if (Guard.isReference(mediaType)) { + return mediaType; + } + return mediaType?.schema ?? mediaType?.itemSchema; +}; + +const generateParameterTypeNode = ( + entryPoint: string, + currentPoint: string, + factory: Factory.Type, + parameter: OpenApi.Parameter, + context: ToTypeNode.Context, + converterContext: ConverterContext.Types, +): string => { + const schema = getParameterSchema(parameter) ?? { type: "null" }; + return ToTypeNode.convert( + entryPoint, + currentPoint, + factory, + schema, + context, + converterContext, + typeof schema === "object" && !Guard.isReference(schema) ? { schemaRoot: schema } : undefined, + ); +}; + export const generateTypeNode = ( entryPoint: string, currentPoint: string, @@ -14,7 +46,7 @@ export const generateTypeNode = ( context: ToTypeNode.Context, converterContext: ConverterContext.Types, ): string => { - return ToTypeNode.convert(entryPoint, currentPoint, factory, parameter.schema || { type: "null" }, context, converterContext); + return generateParameterTypeNode(entryPoint, currentPoint, factory, parameter, context, converterContext); }; export const generateTypeAlias = ( @@ -71,14 +103,7 @@ export const generatePropertySignatureObject = ( name: name, optional: isPathProperty ? false : !reference.data.required, comment: reference.data.description, - type: ToTypeNode.convert( - entryPoint, - reference.referencePoint, - factory, - reference.data.schema || { type: "null" }, - context, - converterContext, - ), + type: generateParameterTypeNode(entryPoint, reference.referencePoint, factory, reference.data, context, converterContext), }); return { name, @@ -91,7 +116,7 @@ export const generatePropertySignatureObject = ( readOnly: false, name: name, optional: isPathProperty ? false : !parameter.required, - type: ToTypeNode.convert(entryPoint, currentPoint, factory, parameter.schema || { type: "null" }, context, converterContext), + type: generateTypeNode(entryPoint, currentPoint, factory, parameter, context, converterContext), comment: parameter.description, }); return { diff --git a/src/internal/OpenApiTools/components/Parameters.ts b/src/internal/OpenApiTools/components/Parameters.ts index 8e7af283..24901df0 100644 --- a/src/internal/OpenApiTools/components/Parameters.ts +++ b/src/internal/OpenApiTools/components/Parameters.ts @@ -31,7 +31,7 @@ export const generateNamespace = ( if (reference.type === "local") { throw new UnSupportError("What is components.parameters local reference?"); } - if (!reference.data.schema) { + if (reference.data.schema === undefined) { return; } Schema.addSchema( diff --git a/src/internal/OpenApiTools/components/PathItem.ts b/src/internal/OpenApiTools/components/PathItem.ts index f55ee0ab..0c4be754 100644 --- a/src/internal/OpenApiTools/components/PathItem.ts +++ b/src/internal/OpenApiTools/components/PathItem.ts @@ -138,6 +138,36 @@ export const generateNamespace = ( converterContext, ); } + // OpenAPI 3.2 で追加された QUERY メソッドを生成します。 + if (pathItem.query) { + Operation.generateNamespace( + entryPoint, + currentPoint, + store, + factory, + basePath, + "QUERY", + pathItem.query, + pathItem.parameters, + context, + converterContext, + ); + } + // OpenAPI 3.2 で追加された additionalOperations の標準外 HTTP メソッドを生成します。 + Object.entries(pathItem.additionalOperations || {}).forEach(([httpMethod, operation]) => { + Operation.generateNamespace( + entryPoint, + currentPoint, + store, + factory, + basePath, + httpMethod, + operation, + pathItem.parameters, + context, + converterContext, + ); + }); if (pathItem.parameters) { Parameters.generateNamespaceWithList(entryPoint, currentPoint, store, factory, pathItem.parameters, context, converterContext); } @@ -282,6 +312,40 @@ export const generateStatements = ( ), ); } + // OpenAPI 3.2 で追加された QUERY メソッドを生成します。 + if (pathItem.query) { + statements.push( + Operation.generateStatements( + entryPoint, + currentPoint, + store, + factory, + requestUri, + "QUERY", + pathItem.query, + pathItem.parameters, + context, + converterContext, + ), + ); + } + // OpenAPI 3.2 で追加された additionalOperations の標準外 HTTP メソッドを生成します。 + Object.entries(pathItem.additionalOperations || {}).forEach(([httpMethod, operation]) => { + statements.push( + Operation.generateStatements( + entryPoint, + currentPoint, + store, + factory, + requestUri, + httpMethod, + operation, + pathItem.parameters, + context, + converterContext, + ), + ); + }); return statements.flat(); }; diff --git a/src/internal/OpenApiTools/components/PathItems.ts b/src/internal/OpenApiTools/components/PathItems.ts index 6af86955..304a3c64 100644 --- a/src/internal/OpenApiTools/components/PathItems.ts +++ b/src/internal/OpenApiTools/components/PathItems.ts @@ -30,7 +30,12 @@ export const generateNamespace = ( if (Guard.isReference(pathItem)) { const reference = Reference.generate(entryPoint, currentPoint, pathItem); if (reference.type === "local") { - throw new UnSupportError("can't use components.pathItems local reference"); + // OpenAPI 3.1 で追加された components.pathItems のローカル参照を展開します。 + const resolvedPathItem = store.getPathItem(reference.path); + if (Guard.isReference(resolvedPathItem)) { + throw new UnSupportError(`can't resolve components.pathItems local reference "${pathItem.$ref}"`); + } + return PathItem.generateNamespace(entryPoint, currentPoint, store, factory, basePath, key, resolvedPathItem, context, converterContext); } if (reference.componentName) { if (key !== reference.name) { diff --git a/src/internal/OpenApiTools/components/Reference.ts b/src/internal/OpenApiTools/components/Reference.ts index 1b022daf..f7fff044 100644 --- a/src/internal/OpenApiTools/components/Reference.ts +++ b/src/internal/OpenApiTools/components/Reference.ts @@ -17,7 +17,8 @@ export type LocalReferencePattern = | "#/components/securitySchemes/" | "#/components/links/" | "#/components/callbacks/" - | "#/components/pathItems/"; + | "#/components/pathItems/" + | "#/components/mediaTypes/"; export interface LocalReference { type: "local"; @@ -69,6 +70,7 @@ const localReferencePatterns: readonly LocalReferencePattern[] = [ "#/components/links/", "#/components/callbacks/", "#/components/pathItems/", + "#/components/mediaTypes/", ]; export const localReferenceComponents = { @@ -82,6 +84,7 @@ export const localReferenceComponents = { "#/components/links/": "components/links", "#/components/callbacks/": "components/callbacks", "#/components/pathItems/": "components/pathItems", + "#/components/mediaTypes/": "components/mediaTypes", } as const; export const getLocalReferencePattern = (reference: OpenApi.Reference) => { diff --git a/src/internal/OpenApiTools/components/Schema.ts b/src/internal/OpenApiTools/components/Schema.ts index f8c522ac..94d22807 100644 --- a/src/internal/OpenApiTools/components/Schema.ts +++ b/src/internal/OpenApiTools/components/Schema.ts @@ -3,8 +3,9 @@ import { FeatureDevelopmentError } from "../../Exception"; import type { Factory } from "../../TsGenerator"; import type * as ConvertContext from "../ConverterContext"; import * as Guard from "../Guard"; +import * as InferredType from "../InferredType"; import * as ToTypeNode from "../toTypeNode"; -import type { AnySchema, ArraySchema, ObjectSchema, PrimitiveSchema } from "../types"; +import type { AnySchema, ArraySchema, ObjectSchema, PrimitiveSchema, TypeArraySchema } from "../types"; import type * as Walker from "../Walker"; import * as ExternalDocumentation from "./ExternalDocumentation"; @@ -35,7 +36,7 @@ export const generatePropertySignatures = ( } const required: string[] = schema.required || []; return Object.entries(schema.properties).map(([propertyName, property]) => { - if (!property) { + if (property === undefined) { return factory.PropertySignature.create({ readOnly: false, name: convertContext.escapePropertySignatureName(propertyName), @@ -50,7 +51,7 @@ export const generatePropertySignatures = ( readOnly: typeof property !== "boolean" ? !!property.readOnly : false, name: convertContext.escapePropertySignatureName(propertyName), optional: !required.includes(propertyName), - type: ToTypeNode.convert(entryPoint, currentPoint, factory, property, context, convertContext, { parent: schema }), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, property, context, convertContext, { parent: schema, schemaRoot: schema }), comment: typeof property !== "boolean" ? [property.title, property.description].filter(v => !!v).join("\n\n") : undefined, }); }); @@ -136,7 +137,7 @@ export const generateArrayTypeAlias = ( export: true, name: convertContext.escapeDeclarationText(name), comment: [schema.title, schema.description].filter(v => !!v).join("\n\n"), - type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext, { schemaRoot: schema }), }); }; @@ -181,11 +182,15 @@ export const generateTypeAlias = ( convertContext: ConvertContext.Types, ): string => { let type: string; + const constTypeNode = ToTypeNode.generateLiteralTypeNode(factory, schema.const); let formatTypeNode: string | undefined; if (schema.format && schema.type !== "any") { formatTypeNode = convertContext.convertFormatTypeNode(schema); } - if (formatTypeNode) { + // OpenAPI 3.1 で追加された JSON Schema の const は enum よりも具体的なリテラル型です。 + if (Object.hasOwn(schema, "const") && constTypeNode) { + type = constTypeNode; + } else if (formatTypeNode) { type = schema.nullable === true ? `(${formatTypeNode})` : formatTypeNode; } else if (schema.enum) { if (Guard.isNumberArray(schema.enum) && (schema.type === "number" || schema.type === "integer")) { @@ -221,15 +226,34 @@ export const generateTypeAlias = ( }); }; +/** OpenAPI 3.1 で追加された type 配列を、複数型の union として出力します。 */ +export const generateTypeAliasForTypeArray = ( + entryPoint: string, + currentPoint: string, + factory: Factory.Type, + name: string, + schema: TypeArraySchema, + context: ToTypeNode.Context, + convertContext: ConvertContext.Types, +): string => { + return factory.TypeAliasDeclaration.create({ + export: true, + name: convertContext.escapeDeclarationText(name), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext, { schemaRoot: schema }), + comment: [schema.title, schema.description].filter(v => !!v).join("\n\n"), + }); +}; + export const generateMultiTypeAlias = ( entryPoint: string, currentPoint: string, factory: Factory.Type, name: string, - schemas: OpenApi.Schema[], + schemas: OpenApi.JSONSchemaDefinition[], context: ToTypeNode.Context, multiType: "oneOf" | "allOf" | "anyOf", convertContext: ConvertContext.Types, + schemaRoot?: OpenApi.Schema, ): string => { const type = ToTypeNode.generateMultiTypeNode( entryPoint, @@ -240,6 +264,7 @@ export const generateMultiTypeAlias = ( ToTypeNode.convert, convertContext, multiType, + schemaRoot, ); return factory.TypeAliasDeclaration.create({ export: true, @@ -255,56 +280,118 @@ export const addSchema = ( factory: Factory.Type, targetPoint: string, declarationName: string, - schema: OpenApi.Schema | undefined, + schema: OpenApi.JSONSchemaDefinition | undefined, context: ToTypeNode.Context, convertContext: ConvertContext.Types, ): void => { - if (!schema) { + if (schema === undefined) { + return; + } + if (typeof schema === "boolean") { + // OpenAPI 3.1 で boolean schema が利用可能になりました。 + store.addStatement(targetPoint, { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(declarationName), + value: factory.TypeAliasDeclaration.create({ + export: true, + name: convertContext.escapeDeclarationText(declarationName), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext), + }), + }); + return; + } + const inferredSchema = InferredType.getInferredType(schema); + if (!inferredSchema) { + store.addStatement(targetPoint, { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(declarationName), + value: generateNotInferedTypeAlias(entryPoint, currentPoint, factory, declarationName, schema, convertContext), + }); + return; + } + const targetSchema = inferredSchema; + if (Guard.isTypeArraySchema(targetSchema)) { + // OpenAPI 3.1 で追加された type 配列をリモート参照先でも union として出力します。 + store.addStatement(targetPoint, { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(declarationName), + value: generateTypeAliasForTypeArray(entryPoint, currentPoint, factory, declarationName, targetSchema, context, convertContext), + }); return; } - if (Guard.isAllOfSchema(schema)) { + if (Guard.isAllOfSchema(targetSchema)) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateMultiTypeAlias(entryPoint, currentPoint, factory, declarationName, schema.allOf, context, "allOf", convertContext), + value: generateMultiTypeAlias( + entryPoint, + currentPoint, + factory, + declarationName, + targetSchema.allOf, + context, + "allOf", + convertContext, + targetSchema, + ), }); - } else if (Guard.isOneOfSchema(schema)) { + } else if (Guard.isOneOfSchema(targetSchema)) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateMultiTypeAlias(entryPoint, currentPoint, factory, declarationName, schema.oneOf, context, "oneOf", convertContext), + value: generateMultiTypeAlias( + entryPoint, + currentPoint, + factory, + declarationName, + targetSchema.oneOf, + context, + "oneOf", + convertContext, + targetSchema, + ), }); - } else if (Guard.isAnyOfSchema(schema)) { + } else if (Guard.isAnyOfSchema(targetSchema)) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateMultiTypeAlias(entryPoint, currentPoint, factory, declarationName, schema.anyOf, context, "allOf", convertContext), + value: generateMultiTypeAlias( + entryPoint, + currentPoint, + factory, + declarationName, + targetSchema.anyOf, + context, + "anyOf", + convertContext, + targetSchema, + ), }); - } else if (Guard.isArraySchema(schema)) { + } else if (Guard.isArraySchema(targetSchema)) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateArrayTypeAlias(entryPoint, currentPoint, factory, declarationName, schema, context, convertContext), + value: generateArrayTypeAlias(entryPoint, currentPoint, factory, declarationName, targetSchema, context, convertContext), }); - } else if (Guard.isObjectSchema(schema)) { - if (schema.nullable) { + } else if (Guard.isObjectSchema(targetSchema)) { + if (targetSchema.nullable) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateTypeAliasDeclarationForObject(entryPoint, currentPoint, factory, declarationName, schema, context, convertContext), + value: generateTypeAliasDeclarationForObject(entryPoint, currentPoint, factory, declarationName, targetSchema, context, convertContext), }); } else { store.addStatement(targetPoint, { kind: "interface", name: convertContext.escapeDeclarationText(declarationName), - value: generateInterface(entryPoint, currentPoint, factory, declarationName, schema, context, convertContext), + value: generateInterface(entryPoint, currentPoint, factory, declarationName, targetSchema, context, convertContext), }); } - } else if (Guard.isPrimitiveSchema(schema)) { + } else if (Guard.isPrimitiveSchema(targetSchema)) { store.addStatement(targetPoint, { kind: "typeAlias", name: convertContext.escapeDeclarationText(declarationName), - value: generateTypeAlias(entryPoint, currentPoint, factory, declarationName, schema, convertContext), + value: generateTypeAlias(entryPoint, currentPoint, factory, declarationName, targetSchema, convertContext), }); } }; diff --git a/src/internal/OpenApiTools/components/Schemas.ts b/src/internal/OpenApiTools/components/Schemas.ts index 152b8ec0..1456f921 100644 --- a/src/internal/OpenApiTools/components/Schemas.ts +++ b/src/internal/OpenApiTools/components/Schemas.ts @@ -16,7 +16,7 @@ export const generateNamespace = ( currentPoint: string, store: Walker.Store, factory: Factory.Type, - schemas: Record, + schemas: Record, context: ToTypeNode.Context, convertContext: ConverterContext.Types, ): void => { @@ -31,14 +31,19 @@ export const generateNamespace = ( const reference = Reference.generate(entryPoint, currentPoint, schema); if (reference.type === "local") { const { maybeResolvedName, depth } = context.resolveReferencePath(currentPoint, reference.path); + const functionalSiblings = Object.entries(schema).filter(([key]) => key !== "$ref" && key !== "summary" && key !== "description"); const createTypeNode = () => { - if (depth === 2) { + if (depth === 2 && functionalSiblings.length === 0) { return factory.TypeReferenceNode.create({ name: convertContext.escapeReferenceDeclarationText(maybeResolvedName), }); } - const schema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); - return ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext, { parent: schema }); + const resolvedSchema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); + const mergedSchema = + functionalSiblings.length === 0 || typeof resolvedSchema === "boolean" + ? resolvedSchema + : { ...resolvedSchema, ...Object.fromEntries(functionalSiblings) }; + return ToTypeNode.convert(entryPoint, currentPoint, factory, mergedSchema, context, convertContext, { parent: schema }); }; return store.addStatement(`${basePath}/${name}`, { kind: "typeAlias", @@ -77,8 +82,24 @@ export const generateNamespace = ( }), }); } - const schema = InferredType.getInferredType(targetSchema); const path = `${basePath}/${name}`; + if (typeof targetSchema === "boolean") { + // OpenAPI 3.1 で components.schemas に JSON Schema の boolean schema が利用可能になりました。 + return store.addStatement( + path, + { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(name), + value: factory.TypeAliasDeclaration.create({ + export: true, + name: convertContext.escapeDeclarationText(name), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, targetSchema, context, convertContext), + }), + }, + { override: true }, + ); + } + const schema = InferredType.getInferredType(targetSchema); if (!schema) { // Outputs Warning because Schema cannot be identified Logger.warn(`Warning: Schema could not be identified. Therefore, it is treated as any. ${name}`); @@ -92,13 +113,24 @@ export const generateNamespace = ( { override: true }, ); } + if (Guard.isTypeArraySchema(schema)) { + return store.addStatement( + path, + { + kind: "typeAlias", + name: convertContext.escapeDeclarationText(name), + value: Schema.generateTypeAliasForTypeArray(entryPoint, currentPoint, factory, name, schema, context, convertContext), + }, + { override: true }, + ); + } if (Guard.isAllOfSchema(schema)) { return store.addStatement( path, { kind: "typeAlias", name: convertContext.escapeDeclarationText(name), - value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.allOf, context, "allOf", convertContext), + value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.allOf, context, "allOf", convertContext, schema), }, { override: true }, ); @@ -109,7 +141,7 @@ export const generateNamespace = ( { kind: "typeAlias", name: convertContext.escapeDeclarationText(name), - value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.oneOf, context, "oneOf", convertContext), + value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.oneOf, context, "oneOf", convertContext, schema), }, { override: true }, ); @@ -120,7 +152,7 @@ export const generateNamespace = ( { kind: "typeAlias", name: convertContext.escapeDeclarationText(name), - value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.anyOf, context, "anyOf", convertContext), + value: Schema.generateMultiTypeAlias(entryPoint, currentPoint, factory, name, schema.anyOf, context, "anyOf", convertContext, schema), }, { override: true }, ); diff --git a/src/internal/OpenApiTools/components/SecuritySchema.ts b/src/internal/OpenApiTools/components/SecuritySchema.ts index 79e9534e..fae6f1e7 100644 --- a/src/internal/OpenApiTools/components/SecuritySchema.ts +++ b/src/internal/OpenApiTools/components/SecuritySchema.ts @@ -7,32 +7,28 @@ export const generatePropertySignatures = ( factory: Factory.Type, securitySchema: OpenApi.SecuritySchema, ): string[] => { - return [ - factory.PropertySignature.create({ - readOnly: false, - name: "type", - optional: false, - type: factory.LiteralTypeNode.create({ value: securitySchema.type }), - }), - factory.PropertySignature.create({ - readOnly: false, - name: "name", - optional: false, - type: factory.LiteralTypeNode.create({ value: securitySchema.name }), - }), - factory.PropertySignature.create({ - readOnly: false, - name: "in", - optional: false, - type: factory.LiteralTypeNode.create({ value: securitySchema.in }), - }), - factory.PropertySignature.create({ - readOnly: false, - name: "openIdConnectUrl", - optional: false, - type: factory.LiteralTypeNode.create({ value: securitySchema.openIdConnectUrl }), - }), + const properties: Array<[string, string | boolean | undefined]> = [ + ["type", securitySchema.type], + ["deprecated", securitySchema.deprecated], + ["name", securitySchema.name], + ["in", securitySchema.in], + ["scheme", securitySchema.scheme], + ["bearerFormat", securitySchema.bearerFormat], + ["openIdConnectUrl", securitySchema.openIdConnectUrl], ]; + return properties.flatMap(([name, value]) => { + if (value === undefined) { + return []; + } + return [ + factory.PropertySignature.create({ + readOnly: false, + name, + optional: false, + type: factory.LiteralTypeNode.create({ value }), + }), + ]; + }); }; export const generateInterface = ( diff --git a/src/internal/OpenApiTools/toTypeNode.ts b/src/internal/OpenApiTools/toTypeNode.ts index e0d92b88..184fefe5 100644 --- a/src/internal/OpenApiTools/toTypeNode.ts +++ b/src/internal/OpenApiTools/toTypeNode.ts @@ -1,5 +1,4 @@ import type { OpenApi } from "../../types"; -import { UnSupportError } from "../Exception"; import * as Logger from "../Logger"; import type { Factory } from "../TsGenerator"; import type * as ConverterContext from "./ConverterContext"; @@ -41,9 +40,10 @@ export type Convert = ( export interface Option { parent?: any; + schemaRoot?: OpenApi.Schema; } -const isSingleElementUnionOrIntersection = (schema: OpenApi.JSONSchema | OpenApi.Reference): boolean => { +const isSingleElementUnionOrIntersection = (schema: OpenApi.JSONSchemaDefinition | OpenApi.Reference): boolean => { if (typeof schema === "boolean" || Guard.isReference(schema)) return false; const s = schema as OpenApi.Schema; if (Guard.isOneOfSchema(s)) return s.oneOf.length === 1; @@ -58,7 +58,7 @@ const isSingleElementUnionOrIntersection = (schema: OpenApi.JSONSchema | OpenApi return false; }; -const wrapIfNeeded = (converted: string, schema: OpenApi.JSONSchema | OpenApi.Reference): string => { +const wrapIfNeeded = (converted: string, schema: OpenApi.JSONSchemaDefinition | OpenApi.Reference): string => { if (isSingleElementUnionOrIntersection(schema) && !converted.startsWith("(")) { return `(${converted})`; } @@ -69,14 +69,15 @@ export const generateMultiTypeNode = ( entryPoint: string, currentPoint: string, factory: Factory.Type, - schemas: OpenApi.JSONSchema[], + schemas: OpenApi.JSONSchemaDefinition[], setReference: Context, convert: Convert, convertContext: ConverterContext.Types, multiType: "oneOf" | "allOf" | "anyOf", + schemaRoot?: OpenApi.Schema, ): string => { const typeNodes = schemas.map(schema => - wrapIfNeeded(convert(entryPoint, currentPoint, factory, schema, setReference, convertContext), schema), + wrapIfNeeded(convert(entryPoint, currentPoint, factory, schema, setReference, convertContext, { schemaRoot }), schema), ); if (multiType === "oneOf") { return factory.UnionTypeNode.create({ @@ -110,6 +111,88 @@ const nullable = (factory: Factory.Type, typeNode: string, isNullable: boolean): return typeNode; }; +const resolveJsonPointer = (root: any, reference: string): any => { + if (!root || !reference.startsWith("#/")) { + return undefined; + } + return reference + .slice(2) + .split("/") + .reduce((current, token) => { + if (current === null || typeof current !== "object") { + return undefined; + } + return current[token.replace(/~1/g, "/").replace(/~0/g, "~")]; + }, root); +}; + +const isLiteralValue = (value: unknown): value is string | boolean | number | null => { + return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number"; +}; + +const isEnumValueForType = (value: unknown, type: OpenApi.JSONSchemaTypeName): boolean => { + if (type === "null") { + return value === null; + } + if (type === "integer" || type === "number") { + return typeof value === "number"; + } + return typeof value === type; +}; + +/** OpenAPI 3.1 で追加された JSON Schema の const を TypeScript のリテラル型へ変換します。 */ +export const generateLiteralTypeNode = (factory: Factory.Type, value: unknown): string | undefined => { + return isLiteralValue(value) ? factory.LiteralTypeNode.create({ value }) : undefined; +}; + +const convertArrayItems = ( + entryPoint: string, + currentPoint: string, + factory: Factory.Type, + schemas: OpenApi.JSONSchemaDefinition[], + context: Context, + convertContext: ConverterContext.Types, + convert: Convert, + schemaRoot?: OpenApi.Schema, +): string[] => { + return schemas.map(schema => convert(entryPoint, currentPoint, factory, schema, context, convertContext, { schemaRoot })); +}; + +const convertTupleType = ( + entryPoint: string, + currentPoint: string, + factory: Factory.Type, + schema: OpenApi.Schema, + context: Context, + convertContext: ConverterContext.Types, + convert: Convert, + schemaRoot?: OpenApi.Schema, +): string => { + // OpenAPI 3.1 で追加された prefixItems は JSON Schema のタプルを表します。 + const prefixItems = schema.prefixItems || (Array.isArray(schema.items) ? schema.items : []); + const items = convertArrayItems(entryPoint, currentPoint, factory, prefixItems, context, convertContext, convert, schemaRoot); + const restSchema = Array.isArray(schema.items) ? schema.additionalItems : schema.items; + if (restSchema !== false) { + const restType = + restSchema === undefined || restSchema === true + ? factory.TypeNode.create({ type: "any" }) + : convert(entryPoint, currentPoint, factory, restSchema, context, convertContext, { parent: schema, schemaRoot }); + const wrappedRestType = hasTopLevelTypeOperator(restType) ? `(${restType})` : restType; + items.push(`...${wrappedRestType}[]`); + } + return `[${items.join(", ")}]`; +}; + +const hasTopLevelTypeOperator = (typeNode: string): boolean => { + let depth = 0; + for (const character of typeNode) { + if (character === "(" || character === "[" || character === "{") depth++; + if (character === ")" || character === "]" || character === "}") depth--; + if (depth === 0 && (character === "|" || character === "&")) return true; + } + return false; +}; + export const convert: Convert = ( entryPoint: string, currentPoint: string, @@ -120,23 +203,44 @@ export const convert: Convert = ( option?: Option, ): string => { if (typeof schema === "boolean") { - // https://swagger.io/docs/specification/data-models/dictionaries/#free-form + // OpenAPI 3.1 で JSON Schema の boolean schema が利用可能になりました。 return factory.TypeNode.create({ - type: "object", - value: [], + type: schema ? "any" : "never", }); } if (Guard.isReference(schema)) { + const referenceSchemaRoot = option?.schemaRoot; + if (!Reference.generateLocalReference(schema) && schema.$ref.startsWith("#/")) { + const resolved = + resolveJsonPointer(referenceSchemaRoot || context.rootSchema, schema.$ref) ?? resolveJsonPointer(context.rootSchema, schema.$ref); + if (resolved !== undefined) { + // OpenAPI 3.1 で JSON Schema のローカルな $defs 参照を解決します。 + const siblings = Object.fromEntries(Object.entries(schema).filter(([key]) => key !== "$ref")); + const resolvedSchema = typeof resolved === "boolean" || Object.keys(siblings).length === 0 ? resolved : { ...resolved, ...siblings }; + return convert(entryPoint, currentPoint, factory, resolvedSchema, context, converterContext, { + parent: schema, + schemaRoot: referenceSchemaRoot, + }); + } + } const reference = Reference.generate(entryPoint, currentPoint, schema); if (reference.type === "local") { // Type Aliasを作成 (or すでにある場合は作成しない) context.setReferenceHandler(currentPoint, reference); const { maybeResolvedName, depth } = context.resolveReferencePath(currentPoint, reference.path); - if (depth === 2) { + const functionalSiblings = Object.entries(schema).filter(([key]) => key !== "$ref" && key !== "summary" && key !== "description"); + if (depth === 2 && functionalSiblings.length === 0) { return factory.TypeReferenceNode.create({ name: converterContext.escapeReferenceDeclarationText(maybeResolvedName) }); } - const resolveSchema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); - return convert(entryPoint, currentPoint, factory, resolveSchema, context, converterContext, { parent: schema }); + const resolvedSchema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); + const resolveSchema = + functionalSiblings.length === 0 || typeof resolvedSchema === "boolean" + ? resolvedSchema + : { ...resolvedSchema, ...Object.fromEntries(functionalSiblings) }; + return convert(entryPoint, currentPoint, factory, resolveSchema, context, converterContext, { + parent: schema, + schemaRoot: referenceSchemaRoot, + }); } // サポートしているディレクトリに対して存在する場合 if (reference.componentName) { @@ -146,31 +250,42 @@ export const convert: Convert = ( return factory.TypeReferenceNode.create({ name: context.resolveReferencePath(currentPoint, reference.path).name }); } // サポートしていないディレクトリに存在する場合、直接Interface、もしくはTypeAliasを作成 - return convert(entryPoint, reference.referencePoint, factory, reference.data, context, converterContext, { parent: schema }); + return convert(entryPoint, reference.referencePoint, factory, reference.data, context, converterContext, { + parent: schema, + schemaRoot: referenceSchemaRoot, + }); } + const schemaRoot = option?.schemaRoot || schema; + if (Guard.isOneOfSchema(schema)) { return nullable( factory, - generateMultiTypeNode(entryPoint, currentPoint, factory, schema.oneOf, context, convert, converterContext, "oneOf"), + generateMultiTypeNode(entryPoint, currentPoint, factory, schema.oneOf, context, convert, converterContext, "oneOf", schemaRoot), !!schema.nullable, ); } if (Guard.isAllOfSchema(schema)) { return nullable( factory, - generateMultiTypeNode(entryPoint, currentPoint, factory, schema.allOf, context, convert, converterContext, "allOf"), + generateMultiTypeNode(entryPoint, currentPoint, factory, schema.allOf, context, convert, converterContext, "allOf", schemaRoot), !!schema.nullable, ); } if (Guard.isAnyOfSchema(schema)) { return nullable( factory, - generateMultiTypeNode(entryPoint, currentPoint, factory, schema.anyOf, context, convert, converterContext, "anyOf"), + generateMultiTypeNode(entryPoint, currentPoint, factory, schema.anyOf, context, convert, converterContext, "anyOf", schemaRoot), !!schema.nullable, ); } + // OpenAPI 3.1 で JSON Schema の const キーワードが利用可能になりました。 + const constTypeNode = generateLiteralTypeNode(factory, schema.const); + if (Object.hasOwn(schema, "const") && constTypeNode) { + return nullable(factory, constTypeNode, !!schema.nullable); + } + if (Guard.isHasNoMembersObject(schema)) { return factory.TypeNode.create({ type: "object", @@ -178,11 +293,38 @@ export const convert: Convert = ( }); } + // OpenAPI 3.1 では type に複数の JSON Schema 型を指定できます。 + if (Array.isArray(schema.type)) { + const typeNodes = schema.type.map(type => + convert( + entryPoint, + currentPoint, + factory, + { + ...schema, + type, + nullable: undefined, + enum: schema.enum?.filter(value => isEnumValueForType(value, type)), + }, + context, + converterContext, + { + parent: schema, + schemaRoot, + }, + ), + ); + return nullable(factory, factory.UnionTypeNode.create({ typeNodes }), !!schema.nullable); + } + // schema.type if (!schema.type) { const inferredSchema = InferredType.getInferredType(schema); if (inferredSchema) { - return convert(entryPoint, currentPoint, factory, inferredSchema, context, converterContext, { parent: schema }); + return convert(entryPoint, currentPoint, factory, inferredSchema, context, converterContext, { + parent: schema, + schemaRoot, + }); } // typeを指定せずに、nullableのみを指定している場合に type object変換する if (typeof schema.nullable === "boolean") { @@ -265,23 +407,34 @@ export const convert: Convert = ( return nullable(factory, typeNode, !!schema.nullable); } case "array": { - if (Array.isArray(schema.items) || typeof schema.items === "boolean") { - throw new UnSupportError(`schema.items = ${JSON.stringify(schema.items)}`); + if (schema.prefixItems || Array.isArray(schema.items)) { + return nullable( + factory, + convertTupleType(entryPoint, currentPoint, factory, schema, context, converterContext, convert, schemaRoot), + !!schema.nullable, + ); } let itemValue: string; - if (schema.items) { + if (schema.items === true) { + itemValue = factory.TypeNode.create({ type: "any" }); + } else if (schema.items === false) { + itemValue = factory.TypeNode.create({ type: "never" }); + } else if (schema.items) { const itemsSchema = schema.items as OpenApi.Schema; const itemFormatType = converterContext.convertFormatTypeNode(itemsSchema); if (itemFormatType) { itemValue = `(${itemFormatType})`; } else { itemValue = wrapIfNeeded( - convert(entryPoint, currentPoint, factory, schema.items, context, converterContext, { parent: schema }), + convert(entryPoint, currentPoint, factory, schema.items, context, converterContext, { + parent: schema, + schemaRoot, + }), schema.items as OpenApi.Schema, ); } } else { - itemValue = factory.TypeNode.create({ type: "undefined" }); + itemValue = factory.TypeNode.create({ type: "any" }); } const typeNode = factory.TypeNode.create({ type: schema.type, value: itemValue }); return nullable(factory, typeNode, !!schema.nullable); @@ -300,7 +453,10 @@ export const convert: Convert = ( return factory.PropertySignature.create({ readOnly: typeof jsonSchema !== "boolean" ? !!jsonSchema.readOnly : false, name: converterContext.escapePropertySignatureName(name), - type: convert(entryPoint, currentPoint, factory, jsonSchema, context, converterContext, { parent: schema.properties }), + type: convert(entryPoint, currentPoint, factory, jsonSchema, context, converterContext, { + parent: schema, + schemaRoot, + }), optional: !required.includes(name), comment: typeof jsonSchema !== "boolean" ? jsonSchema.description : undefined, }); @@ -310,6 +466,7 @@ export const convert: Convert = ( name: "key", type: convert(entryPoint, currentPoint, factory, schema.additionalProperties, context, converterContext, { parent: schema.properties, + schemaRoot, }), }); @@ -364,6 +521,7 @@ export const convertAdditionalProperties = ( name: "key", type: convert(entryPoint, currentPoint, factory, schema.additionalProperties, setReference, convertContext, { parent: schema.properties, + schemaRoot: schema, }), }); }; diff --git a/src/internal/OpenApiTools/types/index.ts b/src/internal/OpenApiTools/types/index.ts index 5d1d8fb7..aeae3c09 100644 --- a/src/internal/OpenApiTools/types/index.ts +++ b/src/internal/OpenApiTools/types/index.ts @@ -1,19 +1,22 @@ import type { OpenApi } from "../../../types"; -export interface UnSupportSchema extends Omit { +/** OpenAPI 3.1 で追加された JSON Schema の型配列です。 */ +export interface TypeArraySchema extends Omit { type: OpenApi.JSONSchemaTypeName[]; } +export type UnSupportSchema = TypeArraySchema; + export interface OneOfSchema extends Omit { - oneOf: OpenApi.JSONSchema[]; + oneOf: OpenApi.JSONSchemaDefinition[]; } export interface AllOfSchema extends Omit { - allOf: OpenApi.JSONSchema[]; + allOf: OpenApi.JSONSchemaDefinition[]; } export interface AnyOfSchema extends Omit { - anyOf: OpenApi.JSONSchema[]; + anyOf: OpenApi.JSONSchemaDefinition[]; } export interface ObjectSchema extends Omit { diff --git a/src/internal/ResolveReference/index.ts b/src/internal/ResolveReference/index.ts index 4bab4f90..02178d45 100644 --- a/src/internal/ResolveReference/index.ts +++ b/src/internal/ResolveReference/index.ts @@ -44,6 +44,33 @@ const isLocalReference = (obj: any): boolean => { return !isRemoteReference(obj); }; +const resolveJsonPointer = (rootSchema: any, reference: string): any => { + if (reference === "#") { + return rootSchema; + } + if (!reference.startsWith("#/")) { + return undefined; + } + return reference + .slice(2) + .split("/") + .reduce((current, token) => { + if (current === null || (typeof current !== "object" && !Array.isArray(current))) { + return undefined; + } + const key = token.replace(/~1/g, "/").replace(/~0/g, "~"); + return current[key]; + }, rootSchema); +}; + +const mergeReferenceSiblings = (reference: any, resolved: any): any => { + if (!isObject(reference) || !isObject(resolved)) { + return resolved; + } + const siblings = Object.fromEntries(Object.entries(reference).filter(([key]) => key !== "$ref")); + return Object.keys(siblings).length > 0 ? { ...resolved, ...siblings } : resolved; +}; + const resolveRemoteReference = (entryPoint: string, currentPoint: string, obj: any, parentKey?: string): any => { // console.log(parentKey); if (Guard.isReference(obj)) { @@ -62,7 +89,7 @@ const resolveRemoteReference = (entryPoint: string, currentPoint: string, obj: a Object.entries(data).forEach(([key, value]) => { data[key] = resolveRemoteReference(entryPoint, referencePoint, value, [parentKey, key].join(".")); }); - return data; + return mergeReferenceSiblings(obj, data); } } return obj; @@ -92,13 +119,18 @@ const resolveLocalReference = (entryPoint: string, currentPoint: string, obj: an if (isLocalReference(obj)) { const ref = Reference.generateLocalReference(obj); if (!ref) { - throw new DevelopmentError( - "This is an implementation error. Please report any reproducible information below.\nhttps://github.com/Himenon/openapi-typescript-code-generator/issues/new/choose\n", - ); + // OpenAPI 3.1 では $defs など components 以外の JSON Pointer も参照できます。 + const resolved = resolveJsonPointer(rootSchema, obj.$ref); + if (resolved === undefined) { + // Schema Object 内の相対 JSON Pointer(例: #/$defs/Foo)は、文書全体ではなく + // その Schema Object を基準に解決するため、ここでは参照を保持します。 + return obj; + } + return mergeReferenceSiblings(obj, escapeFromJsonCyclic(resolved)); } // "." in the key const escapedPath = ref.path.replace(/\./g, "\\.").replace(/\//g, "."); - return escapeFromJsonCyclic(DotProp.getProperty(rootSchema, escapedPath)); + return mergeReferenceSiblings(obj, escapeFromJsonCyclic(DotProp.getProperty(rootSchema, escapedPath))); } return obj; } diff --git a/src/internal/TsGenerator/factory.ts b/src/internal/TsGenerator/factory.ts index 2492b808..c2e3c25b 100644 --- a/src/internal/TsGenerator/factory.ts +++ b/src/internal/TsGenerator/factory.ts @@ -129,7 +129,7 @@ export interface Type { create(p: { text: string }): string; }; LiteralTypeNode: { - create(p: { value: string | boolean | number; comment?: string }): string; + create(p: { value: string | boolean | number | null; comment?: string }): string; }; TypeNode: { create( @@ -319,6 +319,9 @@ export const create = (): Type => { LiteralTypeNode: { create(p) { const node = (() => { + if (p.value === null) { + return "null"; + } if (typeof p.value === "string") { const escaped = p.value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); return `"${escaped}"`; diff --git a/src/internal/Validator/__tests__/openapi-3x.test.ts b/src/internal/Validator/__tests__/openapi-3x.test.ts new file mode 100644 index 00000000..a960d8c6 --- /dev/null +++ b/src/internal/Validator/__tests__/openapi-3x.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, test } from "vitest"; +import { CodeGenerator } from "../../../"; +import * as Templates from "../../../templates"; +import type * as Types from "../../../types"; +import { validate } from "../index"; + +const openapi31Document: Types.OpenApi.Document = { + openapi: "3.1.0", + info: { + title: "OpenAPI 3.1 test", + version: "1.0.0", + license: { + name: "MIT", + identifier: "MIT", + }, + }, + servers: [{ url: "/" }], + jsonSchemaDialect: "https://spec.openapis.org/oas/3.1/dialect/base", + webhooks: { + userCreated: { + post: { + requestBody: { + content: { + "application/json": { + schema: { + type: ["object", "null"], + $schema: "https://json-schema.org/draft/2020-12/schema", + }, + }, + }, + }, + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + components: { + pathItems: { + UserCreated: { + post: { + operationId: "userCreated", + responses: { + "202": { + description: "Accepted", + }, + }, + }, + }, + }, + schemas: { + Nullable: { + type: ["string", "null"], + }, + MixedEnum: { + enum: ["ready", null], + }, + Tuple: { + type: "array", + prefixItems: [{ type: "string" }, { type: "number" }], + items: false, + }, + AllowAll: true, + DenyAll: false, + ObjectWithBooleanSchema: { + type: "object", + properties: { + denied: false, + }, + }, + WithDefs: { + type: "object", + $defs: { + Status: { const: "ready" }, + }, + properties: { + status: { $ref: "#/$defs/Status" }, + }, + }, + State: { + const: "ready", + }, + RefNullable: { + $ref: "#/components/schemas/State", + nullable: true, + }, + }, + }, +}; + +const openapi32Document: Types.OpenApi.Document = { + openapi: "3.2.0", + $self: "https://example.com/openapi", + info: { + title: "OpenAPI 3.2 test", + summary: "A short summary", + version: "1.0.0", + license: { + name: "MIT", + identifier: "MIT", + }, + }, + servers: [{ url: "/", name: "default" }], + tags: [ + { + name: "users", + summary: "Users", + parent: "resources", + kind: "nav", + }, + ], + paths: { + "/users": { + get: { + operationId: "listUsers", + parameters: [ + { + name: "filter", + in: "querystring", + content: { + "application/json": { + schema: { type: "object" }, + }, + }, + }, + ], + responses: { + "200": { + summary: "Users response", + description: "OK", + content: { + "application/jsonl": { + itemSchema: { type: "string" }, + }, + "application/x-ndjson": { + $ref: "#/components/mediaTypes/LogEntry", + summary: "Log entries", + }, + }, + }, + }, + }, + query: { + operationId: "queryUsers", + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { type: "string" }, + }, + }, + }, + }, + }, + additionalOperations: { + PURGE: { + operationId: "purgeUsers", + responses: { + "204": { + description: "No Content", + content: { + "application/json": { + schema: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + mediaTypes: { + LogEntry: { + description: "A streamed log entry", + itemSchema: { type: "string" }, + prefixEncoding: [{ contentType: "text/plain" }], + itemEncoding: { contentType: "text/plain" }, + }, + }, + examples: { + LogEntry: { + dataValue: "ready", + serializedValue: "ready", + }, + }, + securitySchemes: { + apiKey: { + type: "apiKey", + name: "X-API-Key", + in: "header", + deprecated: true, + }, + }, + schemas: { + XmlNode: { + type: "object", + xml: { nodeType: "element" }, + discriminator: { + propertyName: "kind", + defaultMapping: "XmlNode", + }, + }, + }, + }, +}; + +describe("OpenAPI 3.x validation", () => { + test("accepts OpenAPI 3.1 JSON Schema and webhook fields", () => { + expect(() => validate(openapi31Document)).not.toThrow(); + }); + + test("accepts OpenAPI 3.2 fields", () => { + expect(() => validate(openapi32Document)).not.toThrow(); + }); +}); + +describe("OpenAPI 3.x generation", () => { + test("generates OpenAPI 3.1 type arrays and const values", () => { + const code = new CodeGenerator(openapi31Document).generateTypeDefinition(); + + expect(code).toContain("export type Nullable = string | null;"); + expect(code).toContain('export type MixedEnum = "ready" | null;'); + expect(code).toContain("export type Tuple = [string, number];"); + expect(code).toContain("export type AllowAll = any;"); + expect(code).toContain("export type DenyAll = never;"); + expect(code).toContain("denied?: never;"); + expect(code).toContain('status?: "ready";'); + expect(code).toContain('export type State = "ready";'); + expect(code).toContain('export type RefNullable = "ready" | null;'); + expect(code).toContain("export namespace PathItems"); + expect(code).toContain("export namespace UserCreated"); + }); + + test("generates OpenAPI 3.2 itemSchema and querystring content", () => { + const generator = new CodeGenerator(openapi32Document); + const code = generator.generateTypeDefinition([generator.getAdditionalTypeDefinitionCustomCodeGenerator()]); + + expect(code).toContain('"application/jsonl": string;'); + expect(code).toContain('"application/x-ndjson": MediaTypes.LogEntry;'); + expect(code).toContain("filter?: {}"); + expect(code).toContain("Response$queryUsers$Status$200"); + expect(code).toContain("Response$purgeUsers$Status$204"); + + const operationParams = generator.getCodeGeneratorParamsArray(); + const listUsers = operationParams.find(({ operationId }) => operationId === "listUsers"); + expect(listUsers?.convertedParams.hasQueryParameters).toBe(true); + expect(listUsers?.convertedParams.pickedParameters).toContainEqual(expect.objectContaining({ in: "querystring" })); + + const clientCode = generator.generateCode([{ generator: Templates.FunctionalApiClient.generator }]); + expect(clientCode).toContain('httpMethod: "QUERY"'); + expect(clientCode).toContain('httpMethod: "PURGE" as HttpMethod'); + }); +}); diff --git a/src/internal/Validator/openapi.json b/src/internal/Validator/openapi.json index 30eb469f..8bbc2d36 100644 --- a/src/internal/Validator/openapi.json +++ b/src/internal/Validator/openapi.json @@ -1,5 +1,5 @@ { - "title": "A JSON Schema for OpenAPI 3.0.", + "title": "A JSON Schema for OpenAPI 3.x.", "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "description": "This is the root document object of the OpenAPI document.", @@ -12,7 +12,8 @@ }, "properties": { "openapi": { - "type": "string" + "type": "string", + "pattern": "^3\\.\\d+\\.\\d+(-.+)?$" }, "info": { "$ref": "#/definitions/info" @@ -24,6 +25,18 @@ }, "uniqueItems": true }, + "webhooks": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/pathItemsOrReference" + } + }, + "jsonSchemaDialect": { + "type": "string" + }, + "$self": { + "type": "string" + }, "paths": { "$ref": "#/definitions/paths" }, @@ -63,6 +76,9 @@ "title": { "type": "string" }, + "summary": { + "type": "string" + }, "description": { "type": "string" }, @@ -117,6 +133,9 @@ }, "url": { "type": "string" + }, + "identifier": { + "type": "string" } } }, @@ -139,6 +158,9 @@ }, "variables": { "$ref": "#/definitions/serverVariables" + }, + "name": { + "type": "string" } } }, @@ -207,6 +229,9 @@ }, "pathItems": { "$ref": "#/definitions/pathItemsOrReferences" + }, + "mediaTypes": { + "$ref": "#/definitions/mediaTypesOrReferences" } } }, @@ -272,6 +297,15 @@ "trace": { "$ref": "#/definitions/operation" }, + "query": { + "$ref": "#/definitions/operation" + }, + "additionalOperations": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/operation" + } + }, "servers": { "type": "array", "items": { @@ -456,9 +490,24 @@ } }, "properties": { + "description": { + "type": "string" + }, "schema": { "$ref": "#/definitions/schemaOrReference" }, + "itemSchema": { + "$ref": "#/definitions/schemaOrReference" + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/definitions/encoding" + } + }, + "itemEncoding": { + "$ref": "#/definitions/encoding" + }, "example": { "$ref": "#/definitions/defaultType" }, @@ -494,6 +543,18 @@ }, "allowReserved": { "type": "boolean" + }, + "encoding": { + "$ref": "#/definitions/encodings" + }, + "prefixEncoding": { + "type": "array", + "items": { + "$ref": "#/definitions/encoding" + } + }, + "itemEncoding": { + "$ref": "#/definitions/encoding" } } }, @@ -518,7 +579,6 @@ "response": { "type": "object", "description": "Describes a single response from an API Operation, including design-time, static `links` to operations based on the response.", - "required": ["description"], "additionalProperties": false, "patternProperties": { "^x-": { @@ -526,6 +586,9 @@ } }, "properties": { + "summary": { + "type": "string" + }, "description": { "type": "string" }, @@ -574,6 +637,12 @@ }, "externalValue": { "type": "string" + }, + "dataValue": { + "$ref": "#/definitions/defaultType" + }, + "serializedValue": { + "type": "string" } } }, @@ -666,11 +735,20 @@ "name": { "type": "string" }, + "summary": { + "type": "string" + }, "description": { "type": "string" }, "externalDocs": { "$ref": "#/definitions/externalDocs" + }, + "parent": { + "type": "string" + }, + "kind": { + "type": "string" } } }, @@ -687,13 +765,19 @@ "properties": { "$ref": { "type": "string" + }, + "summary": { + "type": "string" + }, + "description": { + "type": "string" } } }, "schema": { "type": "object", "description": "The Schema Object allows the definition of input and output data types. These types can be objects, but also primitives and arrays. This object is an extended subset of the JSON Schema Specification Wright Draft 00. For more information about the properties, see JSON Schema Core and JSON Schema Validation. Unless stated otherwise, the property definitions follow the JSON Schema.", - "additionalProperties": false, + "additionalProperties": true, "patternProperties": { "^x-": { "$ref": "#/definitions/specificationExtension" @@ -753,13 +837,13 @@ "$ref": "http://json-schema.org/draft-07/schema#/properties/maximum" }, "exclusiveMaximum": { - "$ref": "http://json-schema.org/draft-07/schema#/properties/exclusiveMaximum" + "type": ["number", "boolean"] }, "minimum": { "$ref": "http://json-schema.org/draft-07/schema#/properties/minimum" }, "exclusiveMinimum": { - "$ref": "http://json-schema.org/draft-07/schema#/properties/exclusiveMinimum" + "type": ["number", "boolean"] }, "maxLength": { "$ref": "http://json-schema.org/draft-07/schema#/properties/maxLength" @@ -792,7 +876,19 @@ "$ref": "http://json-schema.org/draft-07/schema#/properties/enum" }, "type": { - "type": "string" + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + ] }, "allOf": { "type": "array", @@ -816,7 +912,7 @@ "minItems": 1 }, "not": { - "$ref": "#/definitions/schema" + "$ref": "#/definitions/schemaOrReference" }, "items": { "anyOf": [ @@ -870,6 +966,9 @@ }, "mapping": { "$ref": "#/definitions/strings" + }, + "defaultMapping": { + "type": "string" } } }, @@ -883,6 +982,9 @@ } }, "properties": { + "nodeType": { + "enum": ["element", "attribute", "text", "cdata", "none"] + }, "name": { "type": "string" }, @@ -914,6 +1016,9 @@ "type": { "type": "string" }, + "deprecated": { + "type": "boolean" + }, "description": { "type": "string" }, @@ -1090,12 +1195,15 @@ ] }, "schemaOrReference": { - "oneOf": [ + "anyOf": [ { "$ref": "#/definitions/schema" }, { "$ref": "#/definitions/reference" + }, + { + "type": "boolean" } ] }, @@ -1148,7 +1256,30 @@ "mediaTypes": { "type": "object", "additionalProperties": { - "$ref": "#/definitions/mediaType" + "$ref": "#/definitions/mediaTypeOrReference" + } + }, + "mediaTypeOrReference": { + "oneOf": [ + { + "$ref": "#/definitions/mediaType" + }, + { + "$ref": "#/definitions/reference" + } + ] + }, + "mediaTypesOrReferences": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/definitions/mediaType" + }, + { + "$ref": "#/definitions/reference" + } + ] } }, "parametersOrReferences": { diff --git a/src/typedef/CodeGenerator.ts b/src/typedef/CodeGenerator.ts index 5a68ae73..927504c7 100644 --- a/src/typedef/CodeGenerator.ts +++ b/src/typedef/CodeGenerator.ts @@ -39,7 +39,7 @@ export interface ConvertedParams { successResponseFirstContentType: string | undefined; // successResponseContentTypes.length === 1 has2OrMoreSuccessResponseContentTypes: boolean; // successResponseContentTypes.length > 1 hasAdditionalHeaders: boolean; // has2OrMoreRequestContentTypes || has2OrMoreSuccessResponseContentTypes - hasQueryParameters: boolean; // parameters.in === "query" && parameters.length > 0 + hasQueryParameters: boolean; // parameters.in が "query" または "querystring" // Arguments hasParameter: boolean; hasRequestBody: boolean; diff --git a/src/typedef/OpenApi.ts b/src/typedef/OpenApi.ts index c352dcc3..04d03454 100644 --- a/src/typedef/OpenApi.ts +++ b/src/typedef/OpenApi.ts @@ -2,7 +2,84 @@ import type { JSONSchema7, JSONSchema7TypeName, JSONSchema7Type as JSONSchemaTyp export type JSONSchemaTypeName = JSONSchema7TypeName; -export interface JSONSchema extends JSONSchema7 { +/** + * OpenAPI 3.0 の Schema Object と、OpenAPI 3.1 以降で採用された + * JSON Schema Draft 2020-12 のキーワードを表します。 + */ +export interface JSONSchema + extends Omit< + JSONSchema7, + | "type" + | "items" + | "additionalItems" + | "additionalProperties" + | "properties" + | "patternProperties" + | "allOf" + | "oneOf" + | "anyOf" + | "not" + | "contains" + | "if" + | "then" + | "else" + | "propertyNames" + | "dependencies" + | "definitions" + | "exclusiveMinimum" + | "exclusiveMaximum" + | "examples" + | "$defs" + > { + /** OpenAPI 3.1 で JSON Schema の型配列が利用可能になりました。 */ + type?: JSONSchemaTypeName | JSONSchemaTypeName[]; + items?: JSONSchemaDefinition | JSONSchemaDefinition[]; + /** OpenAPI 3.1 で JSON Schema のタプル用キーワード prefixItems が追加されました。 */ + prefixItems?: JSONSchemaDefinition[]; + additionalItems?: JSONSchemaDefinition; + additionalProperties?: JSONSchemaDefinition; + properties?: Record; + patternProperties?: Record; + allOf?: JSONSchemaDefinition[]; + oneOf?: JSONSchemaDefinition[]; + anyOf?: JSONSchemaDefinition[]; + not?: JSONSchemaDefinition; + contains?: JSONSchemaDefinition; + if?: JSONSchemaDefinition; + then?: JSONSchemaDefinition; + else?: JSONSchemaDefinition; + propertyNames?: JSONSchemaDefinition; + dependencies?: Record; + definitions?: Record; + /** OpenAPI 3.1 で $defs が追加され、definitions の後継になりました。 */ + $defs?: Record; + /** OpenAPI 3.1 で追加された JSON Schema の動的参照用キーワードです。 */ + $dynamicRef?: string; + /** OpenAPI 3.1 で追加された JSON Schema の動的アンカーです。 */ + $dynamicAnchor?: string; + /** OpenAPI 3.1 で追加された JSON Schema のアンカーです。 */ + $anchor?: string; + /** OpenAPI 3.1 で追加された JSON Schema vocabulary の宣言です。 */ + $vocabulary?: Record; + /** OpenAPI 3.1 では排他的境界値が boolean から number に変更されました。 */ + exclusiveMinimum?: number | boolean; + /** OpenAPI 3.1 では排他的境界値が boolean から number に変更されました。 */ + exclusiveMaximum?: number | boolean; + /** OpenAPI 3.1 の JSON Schema examples は複数値を持てます。 */ + examples?: JSONSchemaType[]; + /** OpenAPI 3.1 で依存スキーマを表す JSON Schema キーワードが追加されました。 */ + dependentSchemas?: Record; + /** OpenAPI 3.1 で依存する必須プロパティを表すキーワードが追加されました。 */ + dependentRequired?: Record; + /** OpenAPI 3.1 で未評価の item と property を表すキーワードが追加されました。 */ + unevaluatedItems?: JSONSchemaDefinition; + unevaluatedProperties?: JSONSchemaDefinition; + /** OpenAPI 3.1 で contains の出現回数を制約するキーワードが追加されました。 */ + minContains?: number; + maxContains?: number; + /** OpenAPI 3.1 で contentEncoding と組み合わせる Schema が追加されました。 */ + contentSchema?: JSONSchemaDefinition; + /** OpenAPI 3.0 の nullable は後方互換のためにサポートします。 */ nullable?: boolean; } @@ -25,6 +102,8 @@ export interface ServerVariable { export interface Server { url: string; description?: string; + /** OpenAPI 3.2 で追加された Server Object の識別名です。 */ + name?: string; variables?: Record; } @@ -33,7 +112,9 @@ export interface Server { */ export interface Reference { $ref: string; + /** OpenAPI 3.1 で Reference Object に追加された要約です。 */ summary?: string; + /** OpenAPI 3.1 で Reference Object に追加された説明です。 */ description?: string; } @@ -42,6 +123,7 @@ export interface Reference { */ export interface License { name: string; + /** OpenAPI 3.1 で追加された SPDX ライセンス識別子です。 */ identifier?: string; url?: string; } @@ -69,6 +151,10 @@ export interface ExternalDocumentation { export interface Example { summary?: string; description?: string; + /** OpenAPI 3.2 で追加された、シリアライズ前の例データです。 */ + dataValue?: any; + /** OpenAPI 3.2 で追加された、シリアライズ済みの例データです。 */ + serializedValue?: string; value?: any; externalValue?: string; } @@ -79,20 +165,21 @@ export interface Example { export interface Parameter { // Fixed Fields name: string; - in: "path" | "query" | "header" | "cookie"; + /** OpenAPI 3.2 で querystring が追加されました。 */ + in: "path" | "query" | "querystring" | "header" | "cookie"; description?: string; - required: boolean; + required?: boolean; deprecated?: boolean; allowEmptyValue?: boolean; style?: "matrix" | "label" | "form" | "simple" | "spaceDelimited" | "pipeDelimited" | "deepObject"; explode?: boolean; allowReserved?: boolean; - schema?: Schema; + schema?: JSONSchemaDefinition; example?: any; examples?: Record; - content?: Record; + content?: Record; } /** @@ -109,13 +196,26 @@ export interface Encoding { style?: string; explode?: boolean; allowReserved?: boolean; + /** OpenAPI 3.2 で追加された、配列の各 prefix item に対応する Encoding です。 */ + prefixEncoding?: Encoding[]; + /** OpenAPI 3.2 で追加された、配列の残りの item に対応する Encoding です。 */ + itemEncoding?: Encoding; + /** OpenAPI 3.2 で追加された、名前付きの入れ子 Encoding です。 */ + encoding?: Record; } /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#mediaTypeObject */ export interface MediaType { - schema?: Schema; + description?: string; + schema?: JSONSchemaDefinition | Reference; + /** OpenAPI 3.2 で追加された、ストリーミングの各 item 用 Schema です。 */ + itemSchema?: JSONSchemaDefinition | Reference; + /** OpenAPI 3.2 で追加された、配列の各 prefix item 用 Encoding です。 */ + prefixEncoding?: Encoding[]; + /** OpenAPI 3.2 で追加された、配列の残りの item 用 Encoding です。 */ + itemEncoding?: Encoding; example?: any; examples?: Record; encoding?: Record; @@ -126,17 +226,19 @@ export interface MediaType { */ export interface RequestBody { description?: string; - content: Record; - required: boolean; // default: false + content: Record; + required?: boolean; // default: false } /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#responseObject */ export interface Response { - description: string; + /** OpenAPI 3.2 で追加された Response の短い要約です。 */ + summary?: string; + description?: string; headers?: Record; - content?: Record; + content?: Record; links?: Record; } @@ -144,7 +246,6 @@ export interface Response { * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#responsesObject */ export interface Responses { - default: Response | Reference; [statusCode: string]: Response | Reference; } @@ -180,13 +281,15 @@ export interface OAuthFlows { */ export interface SecuritySchema { type: "apiKey" | "http" | "mutualTLS" | "oauth2" | "openIdConnect"; + /** OpenAPI 3.2 で追加された Security Scheme の非推奨フラグです。 */ + deprecated?: boolean; description?: string; - name: string; - in: "query" | "header" | "cookie"; - scheme: string; + name?: string; + in?: "query" | "header" | "cookie"; + scheme?: string; bearerFormat?: string; - flows: OAuthFlows; - openIdConnectUrl: string; + flows?: OAuthFlows; + openIdConnectUrl?: string; } /** @@ -201,13 +304,17 @@ export interface SecurityRequirement { */ export interface Discriminator { propertyName: string; - mapping: Record; + mapping?: Record; + /** OpenAPI 3.2 で追加された、判別プロパティがない場合の既定マッピングです。 */ + defaultMapping?: string; } /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#discriminatorObject */ export interface XML { + /** OpenAPI 3.2 で追加された XML ノード種別です。 */ + nodeType?: "element" | "attribute" | "text" | "cdata" | "none"; name?: string; namespace?: string; prefix?: string; @@ -233,7 +340,7 @@ export interface Link { operationId?: string; parameters?: Record; requestBody?: Record; - description: string; + description?: string; server?: Server; } @@ -270,6 +377,10 @@ export interface PathItem { head?: Operation; patch?: Operation; trace?: Operation; + /** OpenAPI 3.2 で追加された、標準外 HTTP メソッドの操作です。 */ + additionalOperations?: Record; + /** OpenAPI 3.2 で追加された QUERY メソッドの操作です。 */ + query?: Operation; servers?: Server[]; parameters?: (Parameter | Reference)[]; } @@ -279,9 +390,10 @@ export interface PathItem { */ export interface Info { title: string; - summary: string; - description: string; - termsOfService: string; + /** OpenAPI 3.2 で追加された API の短い要約です。 */ + summary?: string; + description?: string; + termsOfService?: string; contact?: Contact; license?: License; version: string; @@ -291,14 +403,14 @@ export interface Info { * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#pathsObject */ export interface Paths { - [path: string]: PathItem; + [path: string]: PathItem | Reference; } /** * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.1.0.md#componentsObject */ export interface Components { - schemas?: Record; + schemas?: Record; responses?: Record; parameters?: Record; examples?: Record; @@ -306,8 +418,11 @@ export interface Components { headers?: Record; securitySchemes?: Record; links?: Record; - callbacks?: Record; - pathItems?: Record; + callbacks?: Record; + /** OpenAPI 3.1 で追加された再利用可能な Path Item です。 */ + pathItems?: Record; + /** OpenAPI 3.2 で追加された再利用可能な Media Type です。 */ + mediaTypes?: Record; } /** @@ -317,6 +432,12 @@ export interface Tag { name: string; description?: string; externalDocs?: ExternalDocumentation; + /** OpenAPI 3.2 で追加された表示用の短い要約です。 */ + summary?: string; + /** OpenAPI 3.2 で追加された親タグ名です。 */ + parent?: string; + /** OpenAPI 3.2 で追加されたタグ分類です。 */ + kind?: string; } /** @@ -325,11 +446,15 @@ export interface Tag { export interface Document { openapi: string; info: Info; + /** OpenAPI 3.1 で追加された Schema Object の既定 JSON Schema dialect です。 */ + jsonSchemaDialect?: string; + /** OpenAPI 3.2 で追加された、この文書自身を表す URI 参照です。 */ + $self?: string; servers?: Server[]; paths?: Paths; webhooks?: Record; components?: Components; - security?: SecurityRequirement; + security?: SecurityRequirement[]; tags?: Tag[]; externalDocs?: ExternalDocumentation; } From 290d80975f521a708e0b207f28e1f5bb1be4eaaa Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:10:44 +0900 Subject: [PATCH 2/4] fix: handle null property schemas --- src/internal/OpenApiTools/components/Schema.ts | 7 ++++--- src/internal/OpenApiTools/toTypeNode.ts | 12 ++++++++++-- .../Validator/__tests__/openapi-3x.test.ts | 18 ++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/internal/OpenApiTools/components/Schema.ts b/src/internal/OpenApiTools/components/Schema.ts index 94d22807..83758b1e 100644 --- a/src/internal/OpenApiTools/components/Schema.ts +++ b/src/internal/OpenApiTools/components/Schema.ts @@ -36,7 +36,7 @@ export const generatePropertySignatures = ( } const required: string[] = schema.required || []; return Object.entries(schema.properties).map(([propertyName, property]) => { - if (property === undefined) { + if (property === undefined || property === null) { return factory.PropertySignature.create({ readOnly: false, name: convertContext.escapePropertySignatureName(propertyName), @@ -48,11 +48,12 @@ export const generatePropertySignatures = ( }); } return factory.PropertySignature.create({ - readOnly: typeof property !== "boolean" ? !!property.readOnly : false, + readOnly: typeof property === "object" && property !== null ? !!property.readOnly : false, name: convertContext.escapePropertySignatureName(propertyName), optional: !required.includes(propertyName), type: ToTypeNode.convert(entryPoint, currentPoint, factory, property, context, convertContext, { parent: schema, schemaRoot: schema }), - comment: typeof property !== "boolean" ? [property.title, property.description].filter(v => !!v).join("\n\n") : undefined, + comment: + typeof property === "object" && property !== null ? [property.title, property.description].filter(v => !!v).join("\n\n") : undefined, }); }); }; diff --git a/src/internal/OpenApiTools/toTypeNode.ts b/src/internal/OpenApiTools/toTypeNode.ts index 184fefe5..0edffeb4 100644 --- a/src/internal/OpenApiTools/toTypeNode.ts +++ b/src/internal/OpenApiTools/toTypeNode.ts @@ -450,15 +450,23 @@ export const convert: Convert = ( } const value: string[] = Object.entries(schema.properties || {}).map(([name, jsonSchema]) => { + if (jsonSchema === undefined || jsonSchema === null) { + return factory.PropertySignature.create({ + readOnly: false, + name: converterContext.escapePropertySignatureName(name), + type: factory.TypeNode.create({ type: "any" }), + optional: !required.includes(name), + }); + } return factory.PropertySignature.create({ - readOnly: typeof jsonSchema !== "boolean" ? !!jsonSchema.readOnly : false, + readOnly: typeof jsonSchema === "object" && jsonSchema !== null ? !!jsonSchema.readOnly : false, name: converterContext.escapePropertySignatureName(name), type: convert(entryPoint, currentPoint, factory, jsonSchema, context, converterContext, { parent: schema, schemaRoot, }), optional: !required.includes(name), - comment: typeof jsonSchema !== "boolean" ? jsonSchema.description : undefined, + comment: typeof jsonSchema === "object" && jsonSchema !== null ? jsonSchema.description : undefined, }); }); if (schema.additionalProperties) { diff --git a/src/internal/Validator/__tests__/openapi-3x.test.ts b/src/internal/Validator/__tests__/openapi-3x.test.ts index a960d8c6..fb5cff1b 100644 --- a/src/internal/Validator/__tests__/openapi-3x.test.ts +++ b/src/internal/Validator/__tests__/openapi-3x.test.ts @@ -259,4 +259,22 @@ describe("OpenAPI 3.x generation", () => { expect(clientCode).toContain('httpMethod: "QUERY"'); expect(clientCode).toContain('httpMethod: "PURGE" as HttpMethod'); }); + + test("treats a null property schema as any for legacy fixtures", () => { + const document = structuredClone(openapi31Document); + document.components = { + schemas: { + NullProperty: { + type: "object", + properties: { + legacy: null as never, + }, + }, + }, + }; + + const code = new CodeGenerator(document).generateTypeDefinition(); + + expect(code).toContain("legacy?: any;"); + }); }); From 7022398008f28daeacbbf52c8ca694e2c7efd729 Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:23:19 +0900 Subject: [PATCH 3/4] fix: stabilize OpenAPI 3.x CI generation --- package.json | 2 +- .../_shared/ApiClientInterface.ts | 10 +++-- src/internal/OpenApiTools/InferredType.ts | 3 +- .../class/__snapshots__/split/types.ts | 42 +++++++++++++++++++ .../typedef-only/api.test.domain.ts | 42 +++++++++++++++++++ .../typedef-with-template/api.test.domain.ts | 42 +++++++++++++++++++ .../typedef-with-template/api.v2.domain.ts | 1 + .../sync-api.test.domain.ts | 42 +++++++++++++++++++ .../functional/__snapshots__/split/types.ts | 42 +++++++++++++++++++ .../typedef-only/api.test.domain.ts | 42 +++++++++++++++++++ .../typedef-with-template/api.test.domain.ts | 42 +++++++++++++++++++ .../typedef-with-template/api.v2.domain.ts | 1 + .../sync-api.test.domain.ts | 42 +++++++++++++++++++ 13 files changed, 347 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4ba23c39..051d1f7a 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "lint": "biome check .", "release:github:registry": "pnpm publish --access public --no-git-checks", "release:npm:registry": "pnpm publish --access public --no-git-checks", - "test": "run-p test:depcruise test:vitest test:code:gen:* test:snapshot", + "test": "run-p test:depcruise test:vitest test:code:gen && run-s test:snapshot", "test:code:gen": "run-p test:code:gen:*", "test:code:gen:class": "pnpm ts ./scripts/testCodeGenWithClass.ts", "test:code:gen:currying-function": "pnpm ts ./scripts/testCodeGenWithCurryingFunctional.ts", diff --git a/src/code-templates/_shared/ApiClientInterface.ts b/src/code-templates/_shared/ApiClientInterface.ts index 50bea812..f281c61b 100644 --- a/src/code-templates/_shared/ApiClientInterface.ts +++ b/src/code-templates/_shared/ApiClientInterface.ts @@ -3,7 +3,7 @@ import type { CodeGenerator } from "../../types"; import type { MethodType } from "./MethodBody/types"; import type { Option } from "./types"; -const httpMethodList: string[] = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE", "QUERY"]; +const httpMethodList: string[] = ["GET", "PUT", "POST", "DELETE", "OPTIONS", "HEAD", "PATCH", "TRACE"]; const createErrorResponsesTypeAlias = (typeName: string, factory: TsGenerator.Factory.Type, errorResponseNames: string[]) => { if (errorResponseNames.length === 0) { @@ -47,11 +47,13 @@ const createSuccessResponseTypeAlias = (typeName: string, factory: TsGenerator.F }); }; -const createHttpMethod = (factory: TsGenerator.Factory.Type) => { +const createHttpMethod = (factory: TsGenerator.Factory.Type, list: CodeGenerator.Params[]) => { + // OpenAPI 3.2 で QUERY が使われる場合だけ、生成するクライアントの HTTP メソッド型に追加します。 + const methods = list.some(item => item.operationParams.httpMethod.toLowerCase() === "query") ? [...httpMethodList, "QUERY"] : httpMethodList; return factory.TypeAliasDeclaration.create({ export: true, name: "HttpMethod", - type: factory.TypeNode.create({ type: "string", enum: httpMethodList }), + type: factory.TypeNode.create({ type: "string", enum: methods }), }); }; @@ -278,7 +280,7 @@ export const create = (factory: TsGenerator.Factory.Type, list: CodeGenerator.Pa }); return [ - createHttpMethod(factory), + createHttpMethod(factory, list), createObjectLikeInterface(factory), ...createQueryParamsDeclarations(factory), createSuccessResponseTypeAlias("SuccessResponses", factory, successResponseNames), diff --git a/src/internal/OpenApiTools/InferredType.ts b/src/internal/OpenApiTools/InferredType.ts index 65bb42f8..9c317c27 100644 --- a/src/internal/OpenApiTools/InferredType.ts +++ b/src/internal/OpenApiTools/InferredType.ts @@ -33,7 +33,8 @@ export const getInferredType = (schema: OpenApi.Schema): OpenApi.Schema | undefi if (enumTypes.length > 1) { return { ...schema, type: enumTypes }; } - return { ...schema, type: enumTypes[0] || "string" }; + // 単一型 enum の既存推論は維持し、OpenAPI 3.1 で追加された異種 enum のみ type 配列へ推論します。 + return { ...schema, type: "string" }; } // type: objectを指定せずに、propertiesのみを指定している場合に type object変換する if (schema.properties) { diff --git a/test/__tests__/class/__snapshots__/split/types.ts b/test/__tests__/class/__snapshots__/split/types.ts index b8bde156..c4ef3d12 100644 --- a/test/__tests__/class/__snapshots__/split/types.ts +++ b/test/__tests__/class/__snapshots__/split/types.ts @@ -293,4 +293,46 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } diff --git a/test/__tests__/class/__snapshots__/typedef-only/api.test.domain.ts b/test/__tests__/class/__snapshots__/typedef-only/api.test.domain.ts index b8bde156..c4ef3d12 100644 --- a/test/__tests__/class/__snapshots__/typedef-only/api.test.domain.ts +++ b/test/__tests__/class/__snapshots__/typedef-only/api.test.domain.ts @@ -293,4 +293,46 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } diff --git a/test/__tests__/class/__snapshots__/typedef-with-template/api.test.domain.ts b/test/__tests__/class/__snapshots__/typedef-with-template/api.test.domain.ts index 38ebb7b7..ab82c73a 100644 --- a/test/__tests__/class/__snapshots__/typedef-with-template/api.test.domain.ts +++ b/test/__tests__/class/__snapshots__/typedef-with-template/api.test.domain.ts @@ -293,6 +293,48 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } export interface Parameter$getIncludeLocalReference { /** parameters.StringQueryParams */ diff --git a/test/__tests__/class/__snapshots__/typedef-with-template/api.v2.domain.ts b/test/__tests__/class/__snapshots__/typedef-with-template/api.v2.domain.ts index a867bac0..793568fa 100644 --- a/test/__tests__/class/__snapshots__/typedef-with-template/api.v2.domain.ts +++ b/test/__tests__/class/__snapshots__/typedef-with-template/api.v2.domain.ts @@ -24,6 +24,7 @@ export interface Response$getHelloWorld$Status$200 { }; } export interface RequestBody$postHelloWorldReadonly { + "application/json": Schemas.ReadOnlyParams; } export interface Response$postHelloWorldReadonly$Status$200 { "application/json": { diff --git a/test/__tests__/class/__snapshots__/typedef-with-template/sync-api.test.domain.ts b/test/__tests__/class/__snapshots__/typedef-with-template/sync-api.test.domain.ts index 53ce2e2f..a262a69f 100644 --- a/test/__tests__/class/__snapshots__/typedef-with-template/sync-api.test.domain.ts +++ b/test/__tests__/class/__snapshots__/typedef-with-template/sync-api.test.domain.ts @@ -293,6 +293,48 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } export interface Parameter$getIncludeLocalReference { /** parameters.StringQueryParams */ diff --git a/test/__tests__/functional/__snapshots__/split/types.ts b/test/__tests__/functional/__snapshots__/split/types.ts index b8bde156..c4ef3d12 100644 --- a/test/__tests__/functional/__snapshots__/split/types.ts +++ b/test/__tests__/functional/__snapshots__/split/types.ts @@ -293,4 +293,46 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } diff --git a/test/__tests__/functional/__snapshots__/typedef-only/api.test.domain.ts b/test/__tests__/functional/__snapshots__/typedef-only/api.test.domain.ts index b8bde156..c4ef3d12 100644 --- a/test/__tests__/functional/__snapshots__/typedef-only/api.test.domain.ts +++ b/test/__tests__/functional/__snapshots__/typedef-only/api.test.domain.ts @@ -293,4 +293,46 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } diff --git a/test/__tests__/functional/__snapshots__/typedef-with-template/api.test.domain.ts b/test/__tests__/functional/__snapshots__/typedef-with-template/api.test.domain.ts index 8eeb297d..3368f552 100644 --- a/test/__tests__/functional/__snapshots__/typedef-with-template/api.test.domain.ts +++ b/test/__tests__/functional/__snapshots__/typedef-with-template/api.test.domain.ts @@ -293,6 +293,48 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } export interface Parameter$getIncludeLocalReference { /** parameters.StringQueryParams */ diff --git a/test/__tests__/functional/__snapshots__/typedef-with-template/api.v2.domain.ts b/test/__tests__/functional/__snapshots__/typedef-with-template/api.v2.domain.ts index 4cfd2f17..f39d2677 100644 --- a/test/__tests__/functional/__snapshots__/typedef-with-template/api.v2.domain.ts +++ b/test/__tests__/functional/__snapshots__/typedef-with-template/api.v2.domain.ts @@ -24,6 +24,7 @@ export interface Response$getHelloWorld$Status$200 { }; } export interface RequestBody$postHelloWorldReadonly { + "application/json": Schemas.ReadOnlyParams; } export interface Response$postHelloWorldReadonly$Status$200 { "application/json": { diff --git a/test/__tests__/functional/__snapshots__/typedef-with-template/sync-api.test.domain.ts b/test/__tests__/functional/__snapshots__/typedef-with-template/sync-api.test.domain.ts index 83fc1a0d..b2440f8f 100644 --- a/test/__tests__/functional/__snapshots__/typedef-with-template/sync-api.test.domain.ts +++ b/test/__tests__/functional/__snapshots__/typedef-with-template/sync-api.test.domain.ts @@ -293,6 +293,48 @@ export namespace RequestBodies { } } } + export namespace ForPaths { + export namespace IncludeRemoteReference { + export interface Content { + "application/json": string; + } + } + } +} +export namespace PathItems { + export namespace IncludeLocalReference { + /** tags: local */ + export namespace GET { + export interface Parameter { + /** parameters.StringQueryParams */ + StringQuery: Parameters.StringQueryParams; + } + export namespace Response { + /** Success response of getA */ + export namespace Status$200 { + export interface Content { + "application/json": { + meta: string; + }; + } + } + } + } + } + export namespace IncludeRemoteReference { + /** tags: remote */ + export namespace GET { + export interface Parameter { + /** remote reference parameter */ + IncludeRemoteReference: number; + } + export type RequestBody = RequestBodies.ForPaths.IncludeRemoteReference.Content; + export namespace Response { + /** OK */ + export namespace Status$200 { } + } + } + } } export interface Parameter$getIncludeLocalReference { /** parameters.StringQueryParams */ From 793576f58a20f4056e79cbcea5cd92eef4e901fa Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:27:45 +0900 Subject: [PATCH 4/4] fix: normalize logical component paths --- src/internal/OpenApiTools/components/Operation.ts | 2 +- src/internal/OpenApiTools/components/RequestBodies.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/internal/OpenApiTools/components/Operation.ts b/src/internal/OpenApiTools/components/Operation.ts index 92253ffe..2e82b303 100644 --- a/src/internal/OpenApiTools/components/Operation.ts +++ b/src/internal/OpenApiTools/components/Operation.ts @@ -1,4 +1,4 @@ -import * as path from "node:path"; +import { posix as path } from "node:path"; import type { OpenApi } from "../../../types"; import type { Factory } from "../../TsGenerator"; diff --git a/src/internal/OpenApiTools/components/RequestBodies.ts b/src/internal/OpenApiTools/components/RequestBodies.ts index ebaf3fb5..5126e72a 100644 --- a/src/internal/OpenApiTools/components/RequestBodies.ts +++ b/src/internal/OpenApiTools/components/RequestBodies.ts @@ -1,4 +1,4 @@ -import * as path from "node:path"; +import { posix as path } from "node:path"; import type { OpenApi } from "../../../types"; import type { Factory } from "../../TsGenerator";