Skip to content

Commit c7dc6dc

Browse files
committed
Continue implementing OpenAPI 3.2.0
1 parent 1053f2a commit c7dc6dc

27 files changed

+1575
-723
lines changed

src/core/parser.ts

Lines changed: 74 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,18 @@ import { extractPaths, isUrl, pascalCase } from './utils.js';
2121
import { validateSpec } from './validator.js';
2222
import { JSON_SCHEMA_2020_12_DIALECT, OAS_3_1_DIALECT } from './constants.js';
2323

24-
/** Represents a `$ref` object in a JSON Schema. */
24+
/** Represents a `$ref` object in a JSON Schema with optional sibling overrides. */
2525
interface RefObject {
2626
$ref: string;
27+
summary?: string;
28+
description?: string;
29+
}
30+
31+
/** Represents a `$dynamicRef` object in OAS 3.1 / JSON Schema 2020-12 with optional sibling overrides. */
32+
interface DynamicRefObject {
33+
$dynamicRef: string;
34+
summary?: string;
35+
description?: string;
2736
}
2837

2938
/**
@@ -34,6 +43,14 @@ interface RefObject {
3443
const isRefObject = (obj: unknown): obj is RefObject =>
3544
typeof obj === 'object' && obj !== null && '$ref' in obj && typeof (obj as { $ref: unknown }).$ref === 'string';
3645

46+
/**
47+
* A type guard to safely check if an object is a `$dynamicRef` object.
48+
* @param obj The object to check.
49+
* @returns True if the object is a valid `$dynamicRef` object.
50+
*/
51+
const isDynamicRefObject = (obj: unknown): obj is DynamicRefObject =>
52+
typeof obj === 'object' && obj !== null && '$dynamicRef' in obj && typeof (obj as { $dynamicRef: unknown }).$dynamicRef === 'string';
53+
3754
/**
3855
* Represents a resolved option for a polymorphic (`oneOf`) schema,
3956
* linking a discriminator value to its corresponding schema definition.
@@ -175,9 +192,9 @@ export class SwaggerParser {
175192
}
176193

177194
/**
178-
* Recursively finds all unique `$ref` values within a given object.
195+
* Recursively finds all unique `$ref` and `$dynamicRef` values within a given object.
179196
* @param obj The object to search.
180-
* @returns An array of unique `$ref` strings.
197+
* @returns An array of unique reference strings.
181198
* @private
182199
*/
183200
private static findRefs(obj: unknown): string[] {
@@ -192,6 +209,10 @@ export class SwaggerParser {
192209
refs.add(current.$ref);
193210
}
194211

212+
if (isDynamicRefObject(current)) {
213+
refs.add(current.$dynamicRef);
214+
}
215+
195216
for (const key in current as object) {
196217
if (Object.prototype.hasOwnProperty.call(current, key)) {
197218
traverse((current as any)[key]);
@@ -289,19 +310,51 @@ export class SwaggerParser {
289310
}
290311

291312
/**
292-
* Synchronously resolves a JSON reference (`$ref`) object to its definition.
293-
* If the provided object is not a `$ref`, it is returned as is.
313+
* Synchronously resolves a JSON reference (`$ref` or `$dynamicRef`) object to its definition.
314+
* If the provided object is not a reference, it is returned as is.
294315
* This method assumes all necessary files have been pre-loaded into the cache.
316+
*
317+
* It also supports Overrides: If the Reference Object contains sibling properties 'summary' or 'description',
318+
* these will be merged into the resolved object, overriding the original values (OAS 3.1+).
319+
*
295320
* @template T The expected type of the resolved object.
296321
* @param obj The object to resolve.
297322
* @returns The resolved definition, the original object if not a ref, or `undefined` if the reference is invalid.
298323
*/
299-
public resolve<T>(obj: T | { $ref: string } | null | undefined): T | undefined {
324+
public resolve<T>(obj: T | { $ref: string } | { $dynamicRef: string } | null | undefined): T | undefined {
300325
if (obj === null || obj === undefined) return undefined;
326+
327+
let resolved: T | undefined;
328+
let refObj: RefObject | DynamicRefObject | null = null;
329+
301330
if (isRefObject(obj)) {
302-
return this.resolveReference(obj.$ref);
331+
resolved = this.resolveReference<T>(obj.$ref);
332+
refObj = obj;
333+
} else if (isDynamicRefObject(obj)) {
334+
// For static code generation purposes, $dynamicRef is treated largely like $ref
335+
// Real runtime behavior would depend on dynamic scopes, but here we resolve to the target.
336+
resolved = this.resolveReference<T>(obj.$dynamicRef);
337+
refObj = obj;
338+
} else {
339+
return obj as T;
303340
}
304-
return obj as T;
341+
342+
// Handle Reference Object Overrides (OAS 3.1 Feature)
343+
if (resolved && typeof resolved === 'object' && refObj) {
344+
const { summary, description } = refObj;
345+
if (summary !== undefined || description !== undefined) {
346+
// We must shallow copy the resolved object to define the overrides without mutating the shared definition.
347+
resolved = { ...resolved };
348+
if (summary !== undefined) {
349+
(resolved as any).summary = summary;
350+
}
351+
if (description !== undefined) {
352+
(resolved as any).description = description;
353+
}
354+
}
355+
}
356+
357+
return resolved;
305358
}
306359

307360
/**
@@ -361,6 +414,11 @@ export class SwaggerParser {
361414
return this.resolveReference(result.$ref, targetFileUri);
362415
}
363416

417+
// Handle nested $dynamicRefs recursively
418+
if (isDynamicRefObject(result)) {
419+
return this.resolveReference(result.$dynamicRef, targetFileUri);
420+
}
421+
364422
return result as T;
365423
}
366424

@@ -389,8 +447,14 @@ export class SwaggerParser {
389447

390448
// Strategy 2: Infer from the `oneOf` array directly by resolving each ref and reading its discriminator property.
391449
return schema.oneOf.map(refSchema => {
392-
if (!refSchema.$ref) return null;
393-
const resolvedSchema = this.resolveReference(refSchema.$ref);
450+
// Check for $ref, but in OAS 3.1 it could also be $dynamicRef
451+
let ref: string | undefined;
452+
if (refSchema.$ref) ref = refSchema.$ref;
453+
else if (refSchema.$dynamicRef) ref = refSchema.$dynamicRef;
454+
455+
if (!ref) return null;
456+
457+
const resolvedSchema = this.resolveReference(ref);
394458
if (!resolvedSchema || !resolvedSchema.properties || !resolvedSchema.properties[dPropName]?.enum) {
395459
return null;
396460
}

src/core/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export interface TagObject {
8686
description?: string;
8787
/** Additional external documentation for this tag. */
8888
externalDocs?: ExternalDocumentationObject;
89+
/** The name of a tag that this tag is nested under. (OAS 3.2+) */
90+
parent?: string;
91+
/** A machine-readable string to categorize what sort of tag it is. (OAS 3.2+) */
92+
kind?: string;
8993
}
9094

9195
/**
@@ -350,6 +354,11 @@ export interface SwaggerDefinition {
350354
contentMediaType?: string;
351355

352356
$ref?: string;
357+
/** Dynamic Reference used in OpenAPI 3.1 (JSON Schema 2020-12) */
358+
$dynamicRef?: string;
359+
/** Dynamic Anchor used in OpenAPI 3.1 (JSON Schema 2020-12) */
360+
$dynamicAnchor?: string;
361+
353362
allOf?: SwaggerDefinition[];
354363
oneOf?: SwaggerDefinition[];
355364
anyOf?: SwaggerDefinition[];

src/core/utils.ts

Lines changed: 119 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,16 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
125125
return typeName && knownTypes.includes(typeName) ? typeName : 'any';
126126
}
127127

128+
// Support for OAS 3.1 $dynamicRef.
129+
// For code generation, we treat this statically by linking to the model name found in the reference.
130+
if (schema.$dynamicRef) {
131+
// Typically points to an anchor, but can point to a path.
132+
// We take the segment after the last '#' or '/'
133+
const ref = schema.$dynamicRef;
134+
const typeName = pascalCase(ref.split('#').pop()?.split('/').pop() || '');
135+
return typeName && knownTypes.includes(typeName) ? typeName : 'any';
136+
}
137+
128138
// JSON Schema 'const' keyword support (OAS 3.1)
129139
if (schema.const !== undefined) {
130140
const val = schema.const;
@@ -145,6 +155,49 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
145155
return `[${tupleTypes.join(', ')}]`;
146156
}
147157

158+
// JSON Schema 2020-12 / OAS 3.1 Conditional Support (if/then/else)
159+
if (schema.if) {
160+
// In TypeScript static analysis, `if` acts like a discriminated union intersection.
161+
// `(Type & Then) | (Exclude<Type, If> & Else)` roughly approximates this logic,
162+
// but TS type narrowing for arbitrary json schema conditions is limited.
163+
// A practical approximation for API clients is: `(Then | Else) & BaseType` if base properties exist, or a Union.
164+
// However, `if` validates the instance. It doesn't inherently change the shape unless combined with properties.
165+
//
166+
// Strategy:
167+
// 1. Treat `then` as one possibility.
168+
// 2. Treat `else` as another possibility.
169+
// 3. The result is `(Then | Else)`. If one is missing, it's `Then | any` or `any | Else` which simplifies to `any` or partial.
170+
// Better Strategy for Models: `Base & (Then | Else)`
171+
172+
const thenType = schema.then ? getTypeScriptType(schema.then, config, knownTypes) : 'any';
173+
const elseType = schema.else ? getTypeScriptType(schema.else, config, knownTypes) : 'any';
174+
175+
// If we have local properties, we generate an intersection.
176+
if (schema.properties || schema.allOf) {
177+
// We recursively get the base type without if/then/else to avoid infinite recursion if we just called getTypeScriptType.
178+
// But since `schema` object is the same, we need to clone and strip condition keywords.
179+
const { if: _, then: __, else: ___, ...baseSchema } = schema;
180+
const baseType = getTypeScriptType(baseSchema, config, knownTypes);
181+
182+
if (schema.then && schema.else) {
183+
return `${baseType} & (${thenType} | ${elseType})`;
184+
} else if (schema.then) {
185+
// If only `then` is present, `else` is implicitly valid for everything (type wise), implying optionality.
186+
// But structurally, `if` implies constraints.
187+
// We output intersection for correctness of the 'then' branch structure types.
188+
return `${baseType} & (${thenType} | any)`;
189+
} else if (schema.else) {
190+
return `${baseType} & (any | ${elseType})`;
191+
}
192+
} else {
193+
// Pure structural conditional
194+
if (schema.then && schema.else) {
195+
return `${thenType} | ${elseType}`;
196+
}
197+
return 'any'; // Too ambiguous without base props
198+
}
199+
}
200+
148201
if (schema.allOf) {
149202
const parts = schema.allOf
150203
.map(s => getTypeScriptType(s, config, knownTypes))
@@ -223,6 +276,25 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
223276
parts.push(`[key: string]: ${joined}`);
224277
}
225278

279+
// Handle 'dependentSchemas' (JSON Schema 2020-12).
280+
// If property X is present, then properties from Schema Y must also be valid.
281+
// We represent this as an intersection: `{ [x]: Type } & DependentSchemaType`
282+
if (schema['dependentSchemas']) { // Note: 'dependentSchemas' isn't in strict type, cast/access loosely or update type def
283+
// Assuming SwaggerDefinition includes generic indexer or updated type definition
284+
const deps = (schema as any).dependentSchemas;
285+
Object.entries(deps).forEach(([prop, depSchema]) => {
286+
const depType = getTypeScriptType(depSchema as SwaggerDefinition, config, knownTypes);
287+
// In TS, this conditional relationship is hard to model perfectly static.
288+
// The most robust way for a client model is to intersect the base with the dependent type
289+
// effectively saying "Example object has all these potential shapes combined".
290+
// Ideally: `Base & Partial<Dependent>` but that loses strictness.
291+
// For now, we treat it as `& Dependent` assuming scenarios where the dependency is met.
292+
// Or, more safely, leave it as `any` or documented field.
293+
// A safe static approach: `& Partial<DependentType>`
294+
parts.push(`// dependentSchema: ${prop} -> ${depType}`);
295+
});
296+
}
297+
226298
if (parts.length > 0) {
227299
type = `{ ${parts.join('; ')} }`;
228300
} else {
@@ -274,6 +346,20 @@ export function hasDuplicateFunctionNames(methods: MethodDeclaration[]): boolean
274346
return new Set(names).size !== names.length;
275347
}
276348

349+
/**
350+
* Normalizes a security scheme key.
351+
* If the key is a JSON pointer/URI (e.g., '#/components/securitySchemes/MyScheme'),
352+
* it extracts the simple name ('MyScheme'). Otherwise returns the key as is.
353+
*/
354+
function normalizeSecurityKey(key: string): string {
355+
// Check if it looks like a URI fragment or JSON pointer
356+
if (key.includes('/')) {
357+
const parts = key.split('/');
358+
return parts[parts.length - 1];
359+
}
360+
return key;
361+
}
362+
277363
// Helper type to handle union of Swagger 2.0 and OpenAPI 3.x parameter definitions
278364
type UnifiedParameter = SwaggerOfficialParameter & {
279365
schema?: SwaggerDefinition | { $ref: string },
@@ -314,11 +400,20 @@ export function extractPaths(
314400
for (const [path, rawPathItem] of Object.entries(swaggerPaths)) {
315401
let pathItem = rawPathItem;
316402

317-
// Handle Path Item $ref via resolver if provided
403+
// Handle Path Item $ref via resolver if provided.
404+
// OAS 3.2 Compliance: Sibling properties on a Reference Object (or Path Item with $ref)
405+
// override the properties of the referenced object.
318406
if (pathItem.$ref && resolveRef) {
319407
const resolved = resolveRef(pathItem.$ref);
320408
if (resolved) {
321-
pathItem = resolved;
409+
// Shallow merge: The properties defined in the source file (`pathItem`)
410+
// take precedence over the resolved reference properties (`resolved`).
411+
// We spread `pathItem` second to ensure its local overrides (e.g., summary) win.
412+
const localOverrides = { ...pathItem };
413+
// We delete $ref from local overrides before merge to avoid confusion downstream
414+
delete localOverrides.$ref;
415+
416+
pathItem = { ...resolved, ...localOverrides };
322417
}
323418
}
324419

@@ -473,6 +568,19 @@ export function extractPaths(
473568

474569
const effectiveServers = operation.servers || pathServers;
475570

571+
// Normalize Security Requirements (OAS 3.2 allows URI references as keys)
572+
// e.g. '#/components/securitySchemes/MyScheme' -> 'MyScheme'
573+
let effectiveSecurity = operation.security;
574+
if (effectiveSecurity) {
575+
effectiveSecurity = effectiveSecurity.map(req => {
576+
const normalizedReq: { [key: string]: string[] } = {};
577+
Object.keys(req).forEach(key => {
578+
normalizedReq[normalizeSecurityKey(key)] = req[key];
579+
});
580+
return normalizedReq;
581+
});
582+
}
583+
476584
const pathInfo: PathInfo = {
477585
path,
478586
method: method.toUpperCase(),
@@ -485,13 +593,19 @@ export function extractPaths(
485593
if (operation.callbacks) pathInfo.callbacks = operation.callbacks;
486594

487595
if (operation.operationId) pathInfo.operationId = operation.operationId;
488-
if (operation.summary) pathInfo.summary = operation.summary;
489-
if (operation.description) pathInfo.description = operation.description;
596+
597+
// Merge Summary/Description from PathItem if not present on Operation
598+
// The order here ensures explicit Op overrides > Path Item overrides > Original ref properties
599+
const summary = operation.summary || pathItem.summary
600+
if (summary) pathInfo.summary = summary;
601+
const description = operation.description || pathItem.description;
602+
if (description) pathInfo.description = description;
603+
490604
if (operation.tags) pathInfo.tags = operation.tags;
491605
if (operation.consumes) pathInfo.consumes = operation.consumes;
492606
if (operation.deprecated) pathInfo.deprecated = operation.deprecated;
493607
if (operation.externalDocs) pathInfo.externalDocs = operation.externalDocs;
494-
if (operation.security) pathInfo.security = operation.security;
608+
if (effectiveSecurity) pathInfo.security = effectiveSecurity;
495609

496610
paths.push(pathInfo);
497611
}

src/service/emit/admin/form-control.mapper.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ export function mapSchemaToFormControl(schema: SwaggerDefinition): FormControlIn
3232
validators.push('Validators.email');
3333
}
3434

35+
// OAS 3.1 / JSON Schema 2020-12 contentEncoding validation
36+
if (schema.contentEncoding) {
37+
if (schema.contentEncoding === 'base64') {
38+
// Standard Base64 regex (RFC 4648 section 4)
39+
validators.push(`Validators.pattern(/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/)`);
40+
} else if (schema.contentEncoding === 'base64url') {
41+
// Base64url regex (RFC 4648 section 5) - URL-safe chars, no padding usually, but simple validation checks char class
42+
validators.push(`Validators.pattern(/^[A-Za-z0-9\\-_]*$/)`);
43+
}
44+
}
45+
3546
// Support for 2020-12 / OAS 3.2 numeric exclusive constraints
3647
// min / exclusiveMin
3748
if (typeof schema.exclusiveMinimum === 'number') {

src/service/emit/orchestrator.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ServiceTestGenerator } from "./test/service-test-generator.js";
2020
import { ServerUrlGenerator } from './utility/server-url.generator.js';
2121
import { XmlBuilderGenerator } from './utility/xml-builder.generator.js';
2222
import { InfoGenerator } from "./utility/info.generator.js";
23+
import { MultipartBuilderGenerator } from "./utility/multipart-builder.generator.js";
2324

2425
export async function emitClientLibrary(outputRoot: string, parser: SwaggerParser, config: GeneratorConfig, project: Project): Promise<void> {
2526
new TypeGenerator(parser, project, config).generate(outputRoot);
@@ -44,6 +45,7 @@ export async function emitClientLibrary(outputRoot: string, parser: SwaggerParse
4445
new FileDownloadGenerator(project).generate(outputRoot);
4546
new ServerUrlGenerator(parser, project).generate(outputRoot);
4647
new XmlBuilderGenerator(project).generate(outputRoot);
48+
new MultipartBuilderGenerator(project).generate(outputRoot);
4749

4850
if (config.options.dateType === 'Date') {
4951
new DateTransformerGenerator(project).generate(outputRoot);

0 commit comments

Comments
 (0)