From 94cd8904d4fb484d7cffdb0bbe24b50584126354 Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:02:53 +0900 Subject: [PATCH 1/2] test: reproduce nested remote schema reference failure --- test/remote.ref.access/v0.yml | 6 ++++++ test/remote.ref.access/v1.yml | 6 ++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/test/remote.ref.access/v0.yml b/test/remote.ref.access/v0.yml index 9b01f562..a1fe9996 100644 --- a/test/remote.ref.access/v0.yml +++ b/test/remote.ref.access/v0.yml @@ -17,11 +17,17 @@ tags: components: schemas: + BookID: + type: string + format: uuid Book: type: object required: + - id - metadata properties: + id: + $ref: "#/components/schemas/BookID" author: type: object properties: diff --git a/test/remote.ref.access/v1.yml b/test/remote.ref.access/v1.yml index 5c5f2ee5..3dc65fa0 100644 --- a/test/remote.ref.access/v1.yml +++ b/test/remote.ref.access/v1.yml @@ -32,8 +32,7 @@ paths: required: true description: Book ID schema: - type: string - format: uuid + $ref: "v0.yml#/components/schemas/BookID" get: operationId: getBook responses: @@ -50,8 +49,7 @@ paths: required: true description: Book ID schema: - type: string - format: uuid + $ref: "v0.yml#/components/schemas/BookID" get: operationId: getDescription responses: From 6989bd0d00862c3628487b47d54d2b4c10f4b182 Mon Sep 17 00:00:00 2001 From: "K.Himeno" <6715229+Himenon@users.noreply.github.com> Date: Mon, 7 Sep 2026 16:42:19 +0900 Subject: [PATCH 2/2] fix: resolve nested remote schema references --- src/internal/OpenApiTools/TypeNodeContext.ts | 77 +++++++++++++++---- .../OpenApiTools/__tests__/Reference.test.ts | 46 +++++++++++ .../__tests__/TypeNodeContext.test.ts | 11 +++ .../OpenApiTools/components/Reference.ts | 23 +++++- .../OpenApiTools/components/Schemas.ts | 4 +- src/internal/OpenApiTools/toTypeNode.ts | 6 +- .../remote-ref-access.ts | 7 +- .../remote-ref-access.ts | 7 +- test/remote.ref.access/v0.yml | 4 +- test/remote.ref.access/v2.yml | 13 ++++ 10 files changed, 167 insertions(+), 31 deletions(-) create mode 100644 src/internal/OpenApiTools/__tests__/Reference.test.ts create mode 100644 test/remote.ref.access/v2.yml diff --git a/src/internal/OpenApiTools/TypeNodeContext.ts b/src/internal/OpenApiTools/TypeNodeContext.ts index e982190a..89c40f50 100644 --- a/src/internal/OpenApiTools/TypeNodeContext.ts +++ b/src/internal/OpenApiTools/TypeNodeContext.ts @@ -3,6 +3,7 @@ import * as DotProp from "dot-prop"; import type { OpenApi } from "../../types"; import { DevelopmentError } from "../Exception"; +import { FileSystem } from "../FileSystem"; import type * as TypeScriptCodeGenerator from "../TsGenerator"; import type * as ConverterContext from "./ConverterContext"; import * as Reference from "./components/Reference"; @@ -30,8 +31,11 @@ export interface ReferencePathSet { * // 返り値の例: { pathArray: ["Common"], base: "components/schemas" } */ export const generatePath = (entryPoint: string, currentPoint: string, referencePath: string): ReferencePathSet => { - const ext = Path.extname(currentPoint); // .yml - const from = Path.relative(Path.dirname(entryPoint), currentPoint).replace(ext, ""); // components/schemas/A/B + // JSON Pointerのフラグメントはドキュメント内の位置を示すため、ファイルシステム上の相対パス計算には影響させない。 + const documentEntryPoint = Reference.getDocumentPoint(entryPoint); + const documentCurrentPoint = Reference.getDocumentPoint(currentPoint); + const ext = Path.extname(documentCurrentPoint); // .yml + const from = Path.relative(Path.dirname(documentEntryPoint), documentCurrentPoint).replace(ext, ""); // components/schemas/A/B const base = Path.dirname(from).replace(Path.sep, "/"); const result = Path.posix.relative(base, referencePath); // remoteの場合? localの場合 referencePath.split("/") const pathArray = result.split("/"); @@ -119,33 +123,50 @@ export const create = ( return calculateReferencePath(store, base, pathArray, converterContext); }; const findSchemaByPathArray = ( + currentPoint: string, pathArray: string[], - remainPathArray: string[] = [], + visited: Set = new Set(), ): OpenApi.Schema | OpenApi.Reference | OpenApi.JSONSchemaDefinition => { - const schema = DotProp.getProperty(rootSchema, pathArray.join(".")); - if (!schema) { - return findSchemaByPathArray(pathArray.slice(0, pathArray.length - 1), [pathArray[pathArray.length - 1], ...remainPathArray]); + // 参照解決はドキュメント境界をまたぐ可能性があるため、エントリポイントではなく現在の参照を所有するドキュメントを基準に解決する。 + const documentPoint = Reference.getDocumentPoint(currentPoint); + // スキーマが自身を参照する場合にコールスタックがあふれないよう、再帰済みの参照を追跡する。 + const visitKey = `${documentPoint}|${pathArray.join("/")}`; + if (visited.has(visitKey)) { + throw new DevelopmentError(`Circular schema reference \n${JSON.stringify({ currentPoint, pathArray }, null, 2)}`); } - if (Guard.isReference(schema)) { - const ref = Reference.generate(entryPoint, entryPoint, schema); - return findSchemaByPathArray(ref.path.split("/"), remainPathArray); - } - if (remainPathArray.length) { - const moreNestSchema = DotProp.getProperty(schema, remainPathArray.join(".")); - if (!moreNestSchema) { - throw new Error("Not found"); + visited.add(visitKey); + + // エントリポイントは既に読み込み済みだが、リモートドキュメントは自身のファイルから読み込む。 + const isEntryPoint = Path.resolve(documentPoint) === Path.resolve(Reference.getDocumentPoint(entryPoint)); + const document = isEntryPoint ? rootSchema : FileSystem.loadJsonOrYaml(documentPoint); + let schema: unknown = document; + // パスを1要素ずつたどり、途中で参照が見つかった場合は参照先ドキュメントで残りのパスを解決する。 + for (const [index, path] of pathArray.entries()) { + schema = DotProp.getProperty(schema, [path]); + if (schema === undefined) { + throw new DevelopmentError( + `Schema not found \n${JSON.stringify({ currentPoint: documentPoint, pathArray, missingPath: pathArray.slice(0, index + 1) }, null, 2)}`, + ); + } + if (Guard.isReference(schema)) { + // 参照をたどると解決対象のドキュメントが変わる可能性がある。 + const ref = Reference.generate(entryPoint, documentPoint, schema); + const nextPoint = ref.type === "local" ? documentPoint : ref.referencePoint; + return findSchemaByPathArray(nextPoint, [...ref.path.split("/"), ...pathArray.slice(index + 1)], visited); } - return moreNestSchema; } - return schema; + if (schema === document) { + throw new DevelopmentError(`Schema path is empty \n${JSON.stringify({ currentPoint: documentPoint, pathArray }, null, 2)}`); + } + return schema as OpenApi.Schema | OpenApi.Reference | OpenApi.JSONSchemaDefinition; }; const setReferenceHandler: ToTypeNode.Context["setReferenceHandler"] = (currentPoint, reference) => { if (store.hasStatement(reference.path, ["interface", "typeAlias"])) { return; } + const context = { rootSchema, setReferenceHandler, resolveReferencePath, findSchemaByPathArray }; if (reference.type === "remote") { const data = reference.data; - const context = { rootSchema, setReferenceHandler, resolveReferencePath, findSchemaByPathArray }; // Determine if the schema should be treated as an interface equivalent // (e.g., plain object schemas that are not nullable and don't produce IntersectionTypeNode) const isInterfaceEquivalent = (() => { @@ -208,6 +229,28 @@ export const create = ( store.addStatement(reference.path, { name: reference.name, kind: "typeAlias", value }); } } else if (reference.type === "local") { + // リモートドキュメント内のローカル参照は、エントリポイントのルートスキーマではなく、 + // そのリモートドキュメントを基準に解決する。 + const isExternalDocument = + Path.resolve(Reference.getDocumentPoint(currentPoint)) !== Path.resolve(Reference.getDocumentPoint(entryPoint)); + if (isExternalDocument) { + const schema = findSchemaByPathArray(currentPoint, reference.path.split("/")); + const declarationName = Path.posix.basename(reference.path); + if (typeof schema === "boolean") { + store.addStatement(reference.path, { + name: declarationName, + kind: "typeAlias", + value: factory.TypeAliasDeclaration.create({ + export: true, + name: converterContext.escapeDeclarationText(declarationName), + type: ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, converterContext), + }), + }); + } else if (!Guard.isReference(schema)) { + Schema.addSchema(entryPoint, currentPoint, store, factory, reference.path, declarationName, schema, context, converterContext); + } + return; + } if (!store.isAfterDefined(reference.path)) { const { maybeResolvedName } = resolveReferencePath(currentPoint, reference.path); const value = factory.TypeAliasDeclaration.create({ diff --git a/src/internal/OpenApiTools/__tests__/Reference.test.ts b/src/internal/OpenApiTools/__tests__/Reference.test.ts new file mode 100644 index 00000000..25680eca --- /dev/null +++ b/src/internal/OpenApiTools/__tests__/Reference.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import * as Reference from "../components/Reference"; + +describe("Reference", () => { + describe("getDocumentPoint", () => { + it("JSON Pointerのフラグメントを除いたファイルパスを返すこと", () => { + expect(Reference.getDocumentPoint("test/remote.ref.access/v0.yml#/components/schemas/Book")).toBe("test/remote.ref.access/v0.yml"); + }); + + it("フラグメントがない場合は入力値をそのまま返すこと", () => { + expect(Reference.getDocumentPoint("test/remote.ref.access/v0.yml")).toBe("test/remote.ref.access/v0.yml"); + }); + }); + + describe("generate", () => { + it("リモート参照のreferencePointをドキュメント単位で返すこと", () => { + const result = Reference.generate("test/remote.ref.access/v1.yml", "test/remote.ref.access/v1.yml", { + $ref: "v0.yml#/components/schemas/Book", + }); + + expect(result).toMatchObject({ + type: "remote", + referencePoint: "test/remote.ref.access/v0.yml", + path: "components/schemas/Book", + }); + }); + }); + + describe("resolveRemoteReference", () => { + it("解決済みリモート参照のreferencePointをドキュメント単位で返すこと", () => { + const result = Reference.resolveRemoteReference("test/remote.ref.access/v1.yml", "test/remote.ref.access/v1.yml", { + $ref: "v0.yml#/components/schemas/Book", + }); + + expect(result.referencePoint).toBe("test/remote.ref.access/v0.yml"); + expect(result.data).toMatchObject({ + type: "object", + properties: { + id: { + $ref: "#/components/schemas/BookIDAlias", + }, + }, + }); + }); + }); +}); diff --git a/src/internal/OpenApiTools/__tests__/TypeNodeContext.test.ts b/src/internal/OpenApiTools/__tests__/TypeNodeContext.test.ts index 504dda68..f8685451 100644 --- a/src/internal/OpenApiTools/__tests__/TypeNodeContext.test.ts +++ b/src/internal/OpenApiTools/__tests__/TypeNodeContext.test.ts @@ -41,6 +41,17 @@ describe("TypeNodeContext", () => { expect(result.base).toBe("components/schemas"); expect(result.pathArray).toEqual(["Common"]); }); + + it("フラグメントを含むリモート参照元でもドキュメント単位でパスを計算できること", () => { + const entryPoint = "test/remote.ref.access/v1.yml"; + const currentPoint = "test/remote.ref.access/v0.yml#/components/schemas/Book"; + const referencePath = "components/schemas/BookID"; + + const result = TypeNodeContext.generatePath(entryPoint, currentPoint, referencePath); + + expect(result.base).toBe("."); + expect(result.pathArray).toEqual(["components", "schemas", "BookID"]); + }); }); describe("calculateReferencePath", () => { diff --git a/src/internal/OpenApiTools/components/Reference.ts b/src/internal/OpenApiTools/components/Reference.ts index 49af15cd..1b022daf 100644 --- a/src/internal/OpenApiTools/components/Reference.ts +++ b/src/internal/OpenApiTools/components/Reference.ts @@ -118,6 +118,17 @@ export const generateReferencePoint = (currentPoint: string, reference: OpenApi. return referencePoint; }; +/** + * JSON Pointerのフラグメントを除いた参照先のファイルパスを返す。 + * + * 参照先はネストした参照を解決する際の現在のドキュメントとして使用する。 + * フラグメントはそのドキュメント内のデータを示すが、ネストした相対参照のパス解決には影響させない。 + */ +export const getDocumentPoint = (referencePoint: string): string => { + const fragmentIndex = referencePoint.indexOf("#/"); + return fragmentIndex === -1 ? referencePoint : referencePoint.substring(0, fragmentIndex); +}; + export const generate = (entryPoint: string, currentPoint: string, reference: OpenApi.Reference): Type => { const localReference = generateLocalReference(reference); if (localReference) { @@ -129,6 +140,8 @@ export const generate = (entryPoint: string, currentPoint: string, reference: } const referencePoint = generateReferencePoint(currentPoint, reference); + // 参照先の値を読み込む際はフラグメントを保持するが、その値に含まれる相対参照はドキュメントパスを基準に解決する。 + const documentPoint = getDocumentPoint(referencePoint); if (!FileSystem.existSync(referencePoint)) { Logger.showFilePosition(entryPoint, currentPoint, referencePoint); @@ -156,12 +169,12 @@ export const generate = (entryPoint: string, currentPoint: string, reference: const data = FileSystem.loadJsonOrYaml(referencePoint); if (Guard.isReference(data)) { - return generate(entryPoint, referencePoint, data); + return generate(entryPoint, documentPoint, data); } return { type: "remote", - referencePoint, + referencePoint: documentPoint, path: targetPath, name: schemaName, componentName: Guard.isComponentName(componentName) ? componentName : undefined, @@ -178,6 +191,8 @@ export const resolveRemoteReference = ( return { referencePoint: currentPoint, data: reference }; } const referencePoint = generateReferencePoint(currentPoint, reference); + // 解決したデータはフラグメントで選択される場合があるが、ネストした相対参照は常に格納元のドキュメントを基準にする。 + const documentPoint = getDocumentPoint(referencePoint); if (!FileSystem.existSync(referencePoint)) { Logger.showFilePosition(entryPoint, currentPoint, referencePoint); Logger.error(JSON.stringify(reference, null, 2)); @@ -185,10 +200,10 @@ export const resolveRemoteReference = ( } const data = FileSystem.loadJsonOrYaml(referencePoint); if (Guard.isReference(data)) { - return resolveRemoteReference(entryPoint, referencePoint, data); + return resolveRemoteReference(entryPoint, documentPoint, data); } return { - referencePoint, + referencePoint: documentPoint, data, }; }; diff --git a/src/internal/OpenApiTools/components/Schemas.ts b/src/internal/OpenApiTools/components/Schemas.ts index 4fe00c5e..152b8ec0 100644 --- a/src/internal/OpenApiTools/components/Schemas.ts +++ b/src/internal/OpenApiTools/components/Schemas.ts @@ -30,14 +30,14 @@ export const generateNamespace = ( const schema = targetSchema; const reference = Reference.generate(entryPoint, currentPoint, schema); if (reference.type === "local") { - const { maybeResolvedName, depth, pathArray } = context.resolveReferencePath(currentPoint, reference.path); + const { maybeResolvedName, depth } = context.resolveReferencePath(currentPoint, reference.path); const createTypeNode = () => { if (depth === 2) { return factory.TypeReferenceNode.create({ name: convertContext.escapeReferenceDeclarationText(maybeResolvedName), }); } - const schema = context.findSchemaByPathArray(pathArray); + const schema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); return ToTypeNode.convert(entryPoint, currentPoint, factory, schema, context, convertContext, { parent: schema }); }; return store.addStatement(`${basePath}/${name}`, { diff --git a/src/internal/OpenApiTools/toTypeNode.ts b/src/internal/OpenApiTools/toTypeNode.ts index 9c1d4b57..e0d92b88 100644 --- a/src/internal/OpenApiTools/toTypeNode.ts +++ b/src/internal/OpenApiTools/toTypeNode.ts @@ -26,7 +26,7 @@ export interface Context { readonly rootSchema: OpenApi.Document; setReferenceHandler: (currentPoint: string, reference: Reference.Type) => void; resolveReferencePath: (currentPoint: string, referencePath: string) => ResolveReferencePath; - findSchemaByPathArray: (paths: string[]) => OpenApi.Schema | OpenApi.Reference | OpenApi.JSONSchemaDefinition; + findSchemaByPathArray: (currentPoint: string, paths: string[]) => OpenApi.Schema | OpenApi.Reference | OpenApi.JSONSchemaDefinition; } export type Convert = ( @@ -131,11 +131,11 @@ export const convert: Convert = ( if (reference.type === "local") { // Type Aliasを作成 (or すでにある場合は作成しない) context.setReferenceHandler(currentPoint, reference); - const { maybeResolvedName, depth, pathArray } = context.resolveReferencePath(currentPoint, reference.path); + const { maybeResolvedName, depth } = context.resolveReferencePath(currentPoint, reference.path); if (depth === 2) { return factory.TypeReferenceNode.create({ name: converterContext.escapeReferenceDeclarationText(maybeResolvedName) }); } - const resolveSchema = context.findSchemaByPathArray(pathArray); + const resolveSchema = context.findSchemaByPathArray(currentPoint, reference.path.split("/")); return convert(entryPoint, currentPoint, factory, resolveSchema, context, converterContext, { parent: schema }); } // サポートしているディレクトリに対して存在する場合 diff --git a/test/__tests__/class/__snapshots__/typedef-with-template/remote-ref-access.ts b/test/__tests__/class/__snapshots__/typedef-with-template/remote-ref-access.ts index d9dde78b..1654a34a 100644 --- a/test/__tests__/class/__snapshots__/typedef-with-template/remote-ref-access.ts +++ b/test/__tests__/class/__snapshots__/typedef-with-template/remote-ref-access.ts @@ -8,7 +8,9 @@ export namespace Schemas { + export type BookIDAlias = string; export interface Book { + id: Schemas.BookIDAlias; author?: { name?: string; age?: string; @@ -40,17 +42,18 @@ export namespace Schemas { } export type Author = Schemas.Book.properties.author; export type Publisher = Schemas.Book.properties.publisher; + export type BookID = string; } export interface Parameter$getBook { /** Book ID */ - id: string; + id: Schemas.BookID; } export interface Response$getBook$Status$200 { "application/json": Schemas.Book; } export interface Parameter$getDescription { /** Book ID */ - id: string; + id: Schemas.BookID; } export interface Response$getDescription$Status$200 { "application/json": Schemas.Book.properties.metadata.properties.description; diff --git a/test/__tests__/functional/__snapshots__/typedef-with-template/remote-ref-access.ts b/test/__tests__/functional/__snapshots__/typedef-with-template/remote-ref-access.ts index c30c8a72..6461e5b8 100644 --- a/test/__tests__/functional/__snapshots__/typedef-with-template/remote-ref-access.ts +++ b/test/__tests__/functional/__snapshots__/typedef-with-template/remote-ref-access.ts @@ -8,7 +8,9 @@ export namespace Schemas { + export type BookIDAlias = string; export interface Book { + id: Schemas.BookIDAlias; author?: { name?: string; age?: string; @@ -40,17 +42,18 @@ export namespace Schemas { } export type Author = Schemas.Book.properties.author; export type Publisher = Schemas.Book.properties.publisher; + export type BookID = string; } export interface Parameter$getBook { /** Book ID */ - id: string; + id: Schemas.BookID; } export interface Response$getBook$Status$200 { "application/json": Schemas.Book; } export interface Parameter$getDescription { /** Book ID */ - id: string; + id: Schemas.BookID; } export interface Response$getDescription$Status$200 { "application/json": Schemas.Book.properties.metadata.properties.description; diff --git a/test/remote.ref.access/v0.yml b/test/remote.ref.access/v0.yml index a1fe9996..33bf339c 100644 --- a/test/remote.ref.access/v0.yml +++ b/test/remote.ref.access/v0.yml @@ -20,6 +20,8 @@ components: BookID: type: string format: uuid + BookIDAlias: + $ref: "v2.yml#/components/schemas/BookID" Book: type: object required: @@ -27,7 +29,7 @@ components: - metadata properties: id: - $ref: "#/components/schemas/BookID" + $ref: "#/components/schemas/BookIDAlias" author: type: object properties: diff --git a/test/remote.ref.access/v2.yml b/test/remote.ref.access/v2.yml new file mode 100644 index 00000000..06c59907 --- /dev/null +++ b/test/remote.ref.access/v2.yml @@ -0,0 +1,13 @@ +openapi: 3.1.0 +info: + version: 1.0.0 + title: v2.remote.ref.access.test + description: Chained remote reference test schema + license: + name: MIT + +components: + schemas: + BookID: + type: string + format: uuid