Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 60 additions & 17 deletions src/internal/OpenApiTools/TypeNodeContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("/");
Expand Down Expand Up @@ -119,33 +123,50 @@ export const create = (
return calculateReferencePath(store, base, pathArray, converterContext);
};
const findSchemaByPathArray = (
currentPoint: string,
pathArray: string[],
remainPathArray: string[] = [],
visited: Set<string> = 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 = (() => {
Expand Down Expand Up @@ -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({
Expand Down
46 changes: 46 additions & 0 deletions src/internal/OpenApiTools/__tests__/Reference.test.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
});
});
});
11 changes: 11 additions & 0 deletions src/internal/OpenApiTools/__tests__/TypeNodeContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
23 changes: 19 additions & 4 deletions src/internal/OpenApiTools/components/Reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(entryPoint: string, currentPoint: string, reference: OpenApi.Reference): Type<T> => {
const localReference = generateLocalReference(reference);
if (localReference) {
Expand All @@ -129,6 +140,8 @@ export const generate = <T>(entryPoint: string, currentPoint: string, reference:
}

const referencePoint = generateReferencePoint(currentPoint, reference);
// 参照先の値を読み込む際はフラグメントを保持するが、その値に含まれる相対参照はドキュメントパスを基準に解決する。
const documentPoint = getDocumentPoint(referencePoint);

if (!FileSystem.existSync(referencePoint)) {
Logger.showFilePosition(entryPoint, currentPoint, referencePoint);
Expand Down Expand Up @@ -156,12 +169,12 @@ export const generate = <T>(entryPoint: string, currentPoint: string, reference:

const data = FileSystem.loadJsonOrYaml(referencePoint);
if (Guard.isReference(data)) {
return generate<T>(entryPoint, referencePoint, data);
return generate<T>(entryPoint, documentPoint, data);
}

return {
type: "remote",
referencePoint,
referencePoint: documentPoint,
path: targetPath,
name: schemaName,
componentName: Guard.isComponentName(componentName) ? componentName : undefined,
Expand All @@ -178,17 +191,19 @@ 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));
throw new NotFoundFileError(`Not found reference point from current point. \n Path: ${referencePoint}`);
}
const data = FileSystem.loadJsonOrYaml(referencePoint);
if (Guard.isReference(data)) {
return resolveRemoteReference(entryPoint, referencePoint, data);
return resolveRemoteReference(entryPoint, documentPoint, data);
}
return {
referencePoint,
referencePoint: documentPoint,
data,
};
};
Expand Down
4 changes: 2 additions & 2 deletions src/internal/OpenApiTools/components/Schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ export const generateNamespace = (
const schema = targetSchema;
const reference = Reference.generate<OpenApi.Schema>(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}`, {
Expand Down
6 changes: 3 additions & 3 deletions src/internal/OpenApiTools/toTypeNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export interface Context {
readonly rootSchema: OpenApi.Document;
setReferenceHandler: (currentPoint: string, reference: Reference.Type<OpenApi.Schema | OpenApi.JSONSchemaDefinition>) => 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 = (
Expand Down Expand Up @@ -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 });
}
// サポートしているディレクトリに対して存在する場合
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@


export namespace Schemas {
export type BookIDAlias = string;
export interface Book {
id: Schemas.BookIDAlias;
author?: {
name?: string;
age?: string;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@


export namespace Schemas {
export type BookIDAlias = string;
export interface Book {
id: Schemas.BookIDAlias;
author?: {
name?: string;
age?: string;
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions test/remote.ref.access/v0.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,19 @@ tags:

components:
schemas:
BookID:
type: string
format: uuid
BookIDAlias:
$ref: "v2.yml#/components/schemas/BookID"
Book:
type: object
required:
- id
- metadata
properties:
id:
$ref: "#/components/schemas/BookIDAlias"
author:
type: object
properties:
Expand Down
6 changes: 2 additions & 4 deletions test/remote.ref.access/v1.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading
Loading