Skip to content

Commit 5772fe4

Browse files
committed
Start implementing OpenAPI 3.2.0
1 parent d0e10c8 commit 5772fe4

File tree

5 files changed

+268
-68
lines changed

5 files changed

+268
-68
lines changed

src/core/parser.ts

Lines changed: 153 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import { SwaggerDefinition, SwaggerSpec, GeneratorConfig, SecurityScheme, PathInfo } from './types.js';
88
import * as fs from 'node:fs';
99
import * as path from 'node:path';
10+
import { pathToFileURL } from 'node:url';
1011
import yaml from 'js-yaml';
1112
import { extractPaths, isUrl, pascalCase } from './utils.js';
1213

@@ -39,26 +40,45 @@ export interface PolymorphicOption {
3940
* helpful utilities like `$ref` resolution.
4041
*/
4142
export class SwaggerParser {
42-
/** The raw, parsed OpenAPI/Swagger specification object. */
43+
/** The raw, parsed OpenAPI/Swagger specification object for the entry document. */
4344
public readonly spec: SwaggerSpec;
4445
/** The configuration object for the generator. */
4546
public readonly config: GeneratorConfig;
46-
/** A normalized array of all top-level schemas (definitions) found in the specification. */
47+
/** The full URI of the entry document. */
48+
public readonly documentUri: string;
49+
50+
/** A normalized array of all top-level schemas (definitions) found in the entry specification. */
4751
public readonly schemas: { name: string; definition: SwaggerDefinition; }[];
48-
/** A flattened and processed list of all API operations (paths). */
52+
/** A flattened and processed list of all API operations (paths) from the entry specification. */
4953
public readonly operations: PathInfo[];
50-
/** A normalized record of all security schemes defined in the specification. */
54+
/** A normalized record of all security schemes defined in the entry specification. */
5155
public readonly security: Record<string, SecurityScheme>;
5256

57+
/** A cache of all loaded specifications, keyed by their absolute URI. */
58+
private readonly specCache: Map<string, SwaggerSpec>;
59+
5360
/**
5461
* Initializes a new instance of the SwaggerParser. It is generally recommended
5562
* to use the static `create` factory method instead of this constructor directly.
56-
* @param spec The raw OpenAPI/Swagger specification object.
63+
* @param spec The raw OpenAPI/Swagger specification object for the entry document.
5764
* @param config The generator configuration.
65+
* @param specCache A map containing all pre-loaded and parsed specifications, including the entry spec.
66+
* @param documentUri The absolute URI of the entry document.
5867
*/
59-
public constructor(spec: SwaggerSpec, config: GeneratorConfig) {
68+
public constructor(
69+
spec: SwaggerSpec,
70+
config: GeneratorConfig,
71+
specCache?: Map<string, SwaggerSpec>,
72+
documentUri: string = 'file://entry-spec.json'
73+
) {
6074
this.spec = spec;
6175
this.config = config;
76+
this.documentUri = documentUri;
77+
78+
// If a cache isn't provided, create one with just the entry spec.
79+
this.specCache = specCache || new Map<string, SwaggerSpec>([[this.documentUri, spec]]);
80+
81+
6282
this.schemas = Object.entries(this.getDefinitions()).map(([name, definition]) => ({
6383
name: pascalCase(name),
6484
definition
@@ -69,15 +89,78 @@ export class SwaggerParser {
6989

7090
/**
7191
* Asynchronously creates a SwaggerParser instance from a file path or URL.
72-
* This is the recommended factory method for creating a parser instance.
73-
* @param inputPath The local file path or remote URL of the OpenAPI/Swagger specification.
92+
* This is the recommended factory method. It pre-loads and caches the entry document
93+
* and any other documents it references.
94+
* @param inputPath The local file path or remote URL of the entry OpenAPI/Swagger specification.
7495
* @param config The generator configuration.
75-
* @returns A promise that resolves to a new SwaggerParser instance.
96+
* @returns A promise that resolves to a new, fully initialized SwaggerParser instance.
7697
*/
7798
static async create(inputPath: string, config: GeneratorConfig): Promise<SwaggerParser> {
78-
const content = await this.loadContent(inputPath);
79-
const spec = this.parseSpecContent(content, inputPath);
80-
return new SwaggerParser(spec, config);
99+
const documentUri = isUrl(inputPath)
100+
? inputPath
101+
: pathToFileURL(path.resolve(process.cwd(), inputPath)).href;
102+
103+
const cache = new Map<string, SwaggerSpec>();
104+
await this.loadAndCacheSpecRecursive(documentUri, cache, new Set<string>());
105+
106+
const entrySpec = cache.get(documentUri)!;
107+
return new SwaggerParser(entrySpec, config, cache, documentUri);
108+
}
109+
110+
/**
111+
* Recursively traverses a specification, loading and caching all external references.
112+
* @param uri The absolute URI of the specification to load.
113+
* @param cache The map where loaded specs are stored.
114+
* @param visited A set to track already processed URIs to prevent infinite loops.
115+
* @private
116+
*/
117+
private static async loadAndCacheSpecRecursive(uri: string, cache: Map<string, SwaggerSpec>, visited: Set<string>): Promise<void> {
118+
if (visited.has(uri)) return;
119+
visited.add(uri);
120+
121+
const content = await this.loadContent(uri);
122+
const spec = this.parseSpecContent(content, uri);
123+
cache.set(uri, spec);
124+
125+
const baseUri = spec.$self ? new URL(spec.$self, uri).href : uri;
126+
127+
const refs = this.findRefs(spec);
128+
for (const ref of refs) {
129+
const [filePath] = ref.split('#', 2);
130+
if (filePath) { // It's a reference to another document
131+
const nextUri = new URL(filePath, baseUri).href;
132+
await this.loadAndCacheSpecRecursive(nextUri, cache, visited);
133+
}
134+
}
135+
}
136+
137+
/**
138+
* Recursively finds all unique `$ref` values within a given object.
139+
* @param obj The object to search.
140+
* @returns An array of unique `$ref` strings.
141+
* @private
142+
*/
143+
private static findRefs(obj: unknown): string[] {
144+
const refs = new Set<string>();
145+
146+
function traverse(current: unknown) {
147+
if (!current || typeof current !== 'object') {
148+
return;
149+
}
150+
151+
if (isRefObject(current)) {
152+
refs.add(current.$ref);
153+
}
154+
155+
for (const key in current as object) {
156+
if (Object.prototype.hasOwnProperty.call(current, key)) {
157+
traverse((current as any)[key]);
158+
}
159+
}
160+
}
161+
162+
traverse(obj);
163+
return Array.from(refs);
81164
}
82165

83166
/**
@@ -88,13 +171,14 @@ export class SwaggerParser {
88171
*/
89172
private static async loadContent(pathOrUrl: string): Promise<string> {
90173
try {
91-
if (isUrl(pathOrUrl)) {
174+
if (isUrl(pathOrUrl) && !pathOrUrl.startsWith('file:')) {
92175
const response = await fetch(pathOrUrl);
93176
if (!response.ok) throw new Error(`Failed to fetch spec from ${pathOrUrl}: ${response.statusText}`);
94177
return response.text();
95178
} else {
96-
if (!fs.existsSync(pathOrUrl)) throw new Error(`Input file not found at ${pathOrUrl}`);
97-
return fs.readFileSync(pathOrUrl, 'utf8');
179+
const filePath = pathOrUrl.startsWith('file:') ? new URL(pathOrUrl).pathname : pathOrUrl;
180+
if (!fs.existsSync(filePath)) throw new Error(`Input file not found at ${filePath}`);
181+
return fs.readFileSync(filePath, 'utf8');
98182
}
99183
} catch (e) {
100184
const message = e instanceof Error ? e.message : String(e);
@@ -124,69 +208,100 @@ export class SwaggerParser {
124208
}
125209
}
126210

127-
/** Retrieves the entire parsed specification object. */
211+
/** Retrieves the entire parsed entry specification object. */
128212
public getSpec(): SwaggerSpec {
129213
return this.spec;
130214
}
131215

132-
/** Retrieves all schema definitions from the specification, normalizing for OpenAPI 3 and Swagger 2. */
216+
/** Retrieves all schema definitions from the entry specification, normalizing for OpenAPI 3 and Swagger 2. */
133217
public getDefinitions(): Record<string, SwaggerDefinition> {
134218
return this.spec.definitions || this.spec.components?.schemas || {};
135219
}
136220

137-
/** Retrieves a single schema definition by its original name from the specification. */
221+
/** Retrieves a single schema definition by its original name from the entry specification. */
138222
public getDefinition(name: string): SwaggerDefinition | undefined {
139223
return this.getDefinitions()[name];
140224
}
141225

142-
/** Retrieves all security scheme definitions from the specification. */
226+
/** Retrieves all security scheme definitions from the entry specification. */
143227
public getSecuritySchemes(): Record<string, SecurityScheme> {
144228
return (this.spec.components?.securitySchemes || this.spec.securityDefinitions || {}) as Record<string, SecurityScheme>;
145229
}
146230

147231
/**
148-
* Resolves a JSON reference (`$ref`) object to its corresponding definition within the specification.
232+
* Synchronously resolves a JSON reference (`$ref`) object to its corresponding definition.
149233
* If the provided object is not a `$ref`, it is returned as is.
234+
* This method assumes all necessary files have been pre-loaded into the cache.
150235
* @template T The expected type of the resolved object.
151236
* @param obj The object to resolve.
152237
* @returns The resolved definition, the original object if not a ref, or `undefined` if the reference is invalid.
153238
*/
154239
public resolve<T>(obj: T | { $ref: string } | null | undefined): T | undefined {
155-
if (obj === null) return null as unknown as undefined;
156-
if (obj === undefined) return undefined;
240+
if (obj === null || obj === undefined) return undefined;
157241
if (isRefObject(obj)) {
158242
return this.resolveReference(obj.$ref);
159243
}
160244
return obj as T;
161245
}
162246

163247
/**
164-
* Resolves a JSON reference string (e.g., '#/components/schemas/User') directly to its definition.
165-
* This robust implementation can traverse any valid local path within the specification.
166-
* It gracefully handles invalid paths and non-local references by returning `undefined`.
248+
* Synchronously resolves a JSON reference string (e.g., './schemas.yaml#/User') to its definition.
249+
* This method reads from the pre-populated cache and can handle nested references.
167250
* @param ref The JSON reference string.
168-
* @returns The resolved definition, or `undefined` if the reference is not found or is invalid.
251+
* @param currentDocUri The absolute URI of the document containing the reference. Defaults to the entry document's base URI.
252+
* @returns The resolved definition, or `undefined` if the reference cannot be resolved.
169253
*/
170-
public resolveReference<T = SwaggerDefinition>(ref: string): T | undefined {
254+
public resolveReference<T = SwaggerDefinition>(ref: string, currentDocUri: string = this.documentUri): T | undefined {
171255
if (typeof ref !== 'string') {
172256
console.warn(`[Parser] Encountered an unsupported or invalid reference: ${ref}`);
173257
return undefined;
174258
}
175-
if (!ref.startsWith('#/')) {
176-
console.warn(`[Parser] Unsupported external or non-root reference: ${ref}`);
259+
260+
const [filePath, jsonPointer] = ref.split('#', 2);
261+
262+
// Get the specification for the current document context to determine its logical base URI.
263+
const currentDocSpec = this.specCache.get(currentDocUri);
264+
265+
// This can happen if an invalid URI is somehow passed as the context.
266+
if (!currentDocSpec) {
267+
console.warn(`[Parser] Unresolved document URI in cache: ${currentDocUri}. Cannot resolve reference "${ref}".`);
177268
return undefined;
178269
}
179-
const pathParts = ref.substring(2).split('/');
180-
let current: unknown = this.spec;
181-
for (const part of pathParts) {
182-
if (typeof current === 'object' && current !== null && Object.prototype.hasOwnProperty.call(current, part)) {
183-
current = (current as Record<string, unknown>)[part];
184-
} else {
185-
console.warn(`[Parser] Failed to resolve reference part "${part}" in path "${ref}"`);
186-
return undefined;
270+
271+
// The base for resolving relative file paths is the document's logical URI, derived from its $self,
272+
// falling back to its physical URI.
273+
const logicalBaseUri = currentDocSpec.$self ? new URL(currentDocSpec.$self, currentDocUri).href : currentDocUri;
274+
275+
// The target file's physical URI is resolved using the logical base. If the ref is local, it's just the current doc's physical URI.
276+
const targetFileUri = filePath ? new URL(filePath, logicalBaseUri).href : currentDocUri;
277+
278+
const targetSpec = this.specCache.get(targetFileUri);
279+
if (!targetSpec) {
280+
console.warn(`[Parser] Unresolved external file reference: ${targetFileUri}. File was not pre-loaded.`);
281+
return undefined;
282+
}
283+
284+
let result: any = targetSpec;
285+
if (jsonPointer) {
286+
// Gracefully handle pointers that are just "/" or empty
287+
const pointerParts = jsonPointer.split('/').filter(p => p !== '');
288+
for (const part of pointerParts) {
289+
const decodedPart = part.replace(/~1/g, '/').replace(/~0/g, '~');
290+
if (typeof result === 'object' && result !== null && Object.prototype.hasOwnProperty.call(result, decodedPart)) {
291+
result = result[decodedPart];
292+
} else {
293+
console.warn(`[Parser] Failed to resolve reference part "${decodedPart}" in path "${ref}" within file ${targetFileUri}`);
294+
return undefined;
295+
}
187296
}
188297
}
189-
return current as T;
298+
299+
// Handle nested $refs recursively, passing the physical URI of the new document context.
300+
if (isRefObject(result)) {
301+
return this.resolveReference(result.$ref, targetFileUri);
302+
}
303+
304+
return result as T;
190305
}
191306

192307
/**

src/core/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
// src/core/types.ts
2+
13
/**
24
* @fileoverview
35
* This file serves as the central repository for all custom TypeScript types and interfaces
@@ -134,6 +136,7 @@ export interface SecurityScheme {
134136
export interface SwaggerSpec {
135137
openapi?: string;
136138
swagger?: string;
139+
$self?: string;
137140
info: Info;
138141
paths: { [pathName: string]: Path };
139142
/** Schema definitions (Swagger 2.0). */

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import * as fs from 'node:fs';
44
import { ModuleKind, Project, ScriptTarget } from 'ts-morph';
5-
import { GeneratorConfig } from './core/types.js';
5+
import { GeneratorConfig, SwaggerSpec } from './core/types.js';
66
import { SwaggerParser } from './core/parser.js';
77
import { emitClientLibrary } from "@src/service/emit/index.js";
88
import { isUrl } from "@src/core/utils.js";
@@ -52,7 +52,10 @@ export async function generateFromConfig(
5252
try {
5353
let swaggerParser: SwaggerParser;
5454
if (isTestEnv) {
55-
swaggerParser = new SwaggerParser(testConfig.spec as any, config);
55+
const docUri = 'file://in-memory-spec.json';
56+
const spec = testConfig.spec as SwaggerSpec;
57+
const cache = new Map<string, SwaggerSpec>([[docUri, spec]]);
58+
swaggerParser = new SwaggerParser(spec, config, cache, docUri);
5659
} else {
5760
swaggerParser = await SwaggerParser.create(config.input, config);
5861
}

0 commit comments

Comments
 (0)