Skip to content

Commit 4bca767

Browse files
committed
Continue implementing OpenAPI 3.2.0
1 parent 2867e6c commit 4bca767

File tree

67 files changed

+3140
-411
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+3140
-411
lines changed

src/analysis/form-model.builder.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,14 @@ export class FormModelBuilder {
220220
controls: subControls
221221
});
222222
}
223+
224+
// Detect default mapping
225+
if (prop.schema.discriminator?.defaultMapping) {
226+
const defaultName = pascalCase(prop.schema.discriminator.defaultMapping.split('/').pop() || '');
227+
if (defaultName && this.result.polymorphicOptions.some(p => p.modelName === defaultName)) {
228+
this.result.defaultPolymorphicOption = defaultName;
229+
}
230+
}
223231
}
224232

225233
private getFormControlTypeString(schema: SwaggerDefinition): string {

src/analysis/form-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,6 @@ export interface FormAnalysisResult {
5555
discriminatorPropName?: string;
5656
discriminatorOptions?: string[]; // List of raw values (['cat', 'dog'])
5757
polymorphicOptions?: PolymorphicOptionModel[]; // Logic for switching
58+
/** The model name of the default option to use if the discriminator value is missing or invalid. */
59+
defaultPolymorphicOption?: string;
5860
}

src/analysis/service-method-analyzer.ts

Lines changed: 274 additions & 27 deletions
Large diffs are not rendered by default.

src/analysis/service-method-types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,28 @@ export type BodyVariant =
2525
| { type: 'encoded-form-data'; paramName: string; mappings: string[] } // For legacy formdata loops
2626
;
2727

28+
/**
29+
* Defines how the response should be deserialized.
30+
*/
31+
export type ResponseSerialization =
32+
| 'json'
33+
| 'text'
34+
| 'blob'
35+
| 'arraybuffer'
36+
| 'sse' // text/event-stream
37+
| 'json-seq' // application/json-seq (RFC 7464)
38+
| 'json-lines' // application/jsonl, application/x-ndjson
39+
| 'xml'; // application/xml
40+
41+
/**
42+
* Describes a potential error response from the API.
43+
*/
44+
export interface ErrorResponseInfo {
45+
code: string;
46+
type: string;
47+
description?: string;
48+
}
49+
2850
/**
2951
* The Intermediate Representation (IR) of a Service Method.
3052
* This model is framework-agnostic regarding *how* the request is made,
@@ -43,6 +65,13 @@ export interface ServiceMethodModel {
4365
parameters: OptionalKind<ParameterDeclarationStructure>[];
4466
responseType: string;
4567

68+
// Response Handling
69+
responseSerialization: ResponseSerialization;
70+
responseXmlConfig?: any;
71+
72+
// Error Handling
73+
errorResponses: ErrorResponseInfo[];
74+
4675
// Request Construction Logic
4776
pathParams: ParamSerialization[];
4877
queryParams: ParamSerialization[];

src/analysis/validation-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export type ValidationRule =
1818
| { type: 'multipleOf'; value: number }
1919
| { type: 'uniqueItems' }
2020
| { type: 'minItems'; value: number }
21-
| { type: 'maxItems'; value: number };
21+
| { type: 'maxItems'; value: number }
22+
| { type: 'const'; value: any };

src/analysis/validation.analyzer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export function analyzeValidationRules(schema: SwaggerDefinition): ValidationRul
1717
// but the resource discovery logic denormalizes this for convenience.
1818
if ((schema as any).required) rules.push({ type: 'required' });
1919

20+
// OAS 3.1 const keyword
21+
if (schema.const !== undefined) {
22+
rules.push({ type: 'const', value: schema.const });
23+
}
24+
2025
if (schema.minLength) rules.push({ type: 'minLength', value: schema.minLength });
2126
if (schema.maxLength) rules.push({ type: 'maxLength', value: schema.maxLength });
2227
if (schema.pattern) rules.push({ type: 'pattern', value: schema.pattern.replace(/\\\\/g, '\\') });

src/core/parser.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,20 @@ export class SwaggerParser {
8787
definition
8888
}));
8989

90-
this.servers = this.spec.servers || [];
90+
// OAS 3.2 Requirement: If the servers field is not provided, or is an empty array,
91+
// the default value would be an array consisting of a single Server Object with a url value of /.
92+
if (this.spec.openapi && (!this.spec.servers || this.spec.servers.length === 0)) {
93+
this.servers = [{ url: '/' }];
94+
} else {
95+
this.servers = this.spec.servers || [];
96+
}
9197

9298
// We bind resolveReference to this instance so extractPaths can call back into the parser
9399
const resolveRef = (ref: string) => this.resolveReference(ref);
94100

95-
this.operations = extractPaths(this.spec.paths, resolveRef);
96-
this.webhooks = extractPaths(this.spec.webhooks, resolveRef);
101+
// Pass components context to extractPaths for strict security matching
102+
this.operations = extractPaths(this.spec.paths, resolveRef, this.spec.components);
103+
this.webhooks = extractPaths(this.spec.webhooks, resolveRef, this.spec.components);
97104

98105
this.security = this.getSecuritySchemes();
99106
this.links = this.getLinks();

src/core/parser/reference-resolver.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const isDynamicRefObject = (obj: unknown): obj is DynamicRefObject =>
2020
$dynamicRef: unknown
2121
}).$dynamicRef === 'string';
2222

23+
/**
24+
* Resolves OpenAPI references ($ref and $dynamicRef) including context-aware resolution for OAS 3.1.
25+
*/
2326
export class ReferenceResolver {
2427
constructor(
2528
private specCache: Map<string, SwaggerSpec>,
@@ -61,6 +64,7 @@ export class ReferenceResolver {
6164

6265
// $dynamicAnchor
6366
if ('$dynamicAnchor' in obj && typeof obj.$dynamicAnchor === 'string') {
67+
// Store mapping for dynamic anchor in the cache map
6468
const anchorUri = `${nextBase}#${obj.$dynamicAnchor}`;
6569
if (!cache.has(anchorUri)) {
6670
cache.set(anchorUri, obj as SwaggerSpec);
@@ -98,17 +102,24 @@ export class ReferenceResolver {
98102
return Array.from(refs);
99103
}
100104

101-
public resolve<T>(obj: T | { $ref: string } | { $dynamicRef: string } | null | undefined): T | undefined {
105+
/**
106+
* Resolves an object that might be a reference.
107+
* @param obj The object to resolve (or null).
108+
* @param resolutionStack The stack of URIs traversed so far (for context-aware $dynamicRef resolution).
109+
*/
110+
public resolve<T>(obj: T | { $ref: string } | {
111+
$dynamicRef: string
112+
} | null | undefined, resolutionStack: string[] = []): T | undefined {
102113
if (obj === null || obj === undefined) return undefined;
103114

104115
let resolved: T | undefined;
105116
let refObj: RefObject | DynamicRefObject | null = null;
106117

107118
if (isRefObject(obj)) {
108-
resolved = this.resolveReference<T>(obj.$ref);
119+
resolved = this.resolveReference<T>(obj.$ref, this.entryDocumentUri, resolutionStack);
109120
refObj = obj;
110121
} else if (isDynamicRefObject(obj)) {
111-
resolved = this.resolveReference<T>(obj.$dynamicRef);
122+
resolved = this.resolveReference<T>(obj.$dynamicRef, this.entryDocumentUri, resolutionStack);
112123
refObj = obj;
113124
} else {
114125
return obj as T;
@@ -127,9 +138,18 @@ export class ReferenceResolver {
127138
return resolved;
128139
}
129140

130-
public resolveReference<T = SwaggerDefinition>(ref: string, currentDocUri: string = this.entryDocumentUri): T | undefined {
141+
/**
142+
* Resolves a specific reference string.
143+
* @param ref The reference string (URI or fragment).
144+
* @param currentDocUri The URI of the document containing the reference.
145+
* @param resolutionStack The stack of unique schema URIs encountered during resolution. Used for $dynamicRef lookup.
146+
*/
147+
public resolveReference<T = SwaggerDefinition>(
148+
ref: string,
149+
currentDocUri: string = this.entryDocumentUri,
150+
resolutionStack: string[] = []
151+
): T | undefined {
131152
if (typeof ref !== 'string') {
132-
console.warn(`[Parser] Encountered an unsupported or invalid reference: ${ref}`);
133153
return undefined;
134154
}
135155

@@ -138,20 +158,34 @@ export class ReferenceResolver {
138158
const logicalBaseUri = currentDocSpec?.$self ? new URL(currentDocSpec.$self, currentDocUri).href : currentDocUri;
139159
const targetUri = filePath ? new URL(filePath, logicalBaseUri).href : logicalBaseUri;
140160

141-
// 1. Direct Cache Lookup ($id/$anchor)
161+
// 1. Dynamic Anchor Resolution (OAS 3.1)
162+
// Dynamic resolution traverses the stack from the outermost (start of resolution)
163+
// to find the first context that defines this anchor.
164+
if (jsonPointer && !jsonPointer.includes('/')) {
165+
for (const scopeUri of resolutionStack) {
166+
const dynamicKey = `${scopeUri}#${jsonPointer}`;
167+
if (this.specCache.has(dynamicKey)) {
168+
return this.specCache.get(dynamicKey) as unknown as T;
169+
}
170+
}
171+
}
172+
173+
// 2. Direct Cache Lookup ($id/$anchor - static)
142174
const fullUriKey = jsonPointer ? `${targetUri}#${jsonPointer}` : targetUri;
143175
if (this.specCache.has(fullUriKey)) {
144176
return this.specCache.get(fullUriKey) as unknown as T;
145177
}
146178

147-
// 2. Spec File Cache Lookup
179+
// 3. Spec File Cache Lookup
148180
const targetSpec = this.specCache.get(targetUri);
149181
if (!targetSpec) {
150-
console.warn(`[Parser] Unresolved external file reference: ${targetUri}. File was not pre-loaded.`);
182+
if (filePath) {
183+
console.warn(`[Parser] Unresolved external file reference: ${targetUri}. File was not pre-loaded.`);
184+
}
151185
return undefined;
152186
}
153187

154-
// 3. JSON Pointer Traversal
188+
// 4. JSON Pointer Traversal
155189
let result: any = targetSpec;
156190
if (jsonPointer) {
157191
const pointerParts = jsonPointer.split('/').filter(p => p !== '');
@@ -167,11 +201,16 @@ export class ReferenceResolver {
167201
}
168202

169203
// Handle nested Refs (Recursive resolution)
170-
if (isRefObject(result)) {
171-
return this.resolveReference(result.$ref, targetUri);
172-
}
173-
if (isDynamicRefObject(result)) {
174-
return this.resolveReference(result.$dynamicRef, targetUri);
204+
if (typeof result === 'object' && result !== null) {
205+
// Push current scope to stack for dynamic resolution downstream
206+
const newStack = [...resolutionStack, fullUriKey];
207+
208+
if (isRefObject(result)) {
209+
return this.resolveReference(result.$ref, targetUri, newStack);
210+
}
211+
if (isDynamicRefObject(result)) {
212+
return this.resolveReference(result.$dynamicRef, targetUri, newStack);
213+
}
175214
}
176215

177216
return result as T;

src/core/types/openapi.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export interface ServerObject {
6868
export interface DiscriminatorObject {
6969
propertyName: string;
7070
mapping?: { [key: string]: string };
71+
defaultMapping?: string;
7172

7273
[key: string]: any;
7374
}
@@ -153,15 +154,21 @@ export interface RequestBody {
153154
required?: boolean;
154155
content?: Record<string, {
155156
schema?: SwaggerDefinition | { $ref: string };
157+
itemSchema?: SwaggerDefinition | { $ref: string };
156158
encoding?: Record<string, EncodingProperty>;
159+
prefixEncoding?: EncodingProperty[];
160+
itemEncoding?: EncodingProperty;
157161
}>;
158162

159163
[key: string]: any;
160164
}
161165

162166
export interface SwaggerResponse {
163167
description?: string;
164-
content?: Record<string, { schema?: SwaggerDefinition | { $ref: string } }>;
168+
content?: Record<string, {
169+
schema?: SwaggerDefinition | { $ref: string };
170+
itemSchema?: SwaggerDefinition | { $ref: string };
171+
}>;
165172
links?: Record<string, LinkObject | { $ref: string }>;
166173
headers?: Record<string, HeaderObject | { $ref: string }>;
167174

src/core/utils/spec-extractor.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,16 @@ type UnifiedParameter = SwaggerOfficialParameter & {
3434
* - Merging path-level and operation-level parameters.
3535
* - Resolving references (if a resolver is provided).
3636
* - Normalizing Swagger 2.0 properties to OpenAPI 3.0 compatible structures.
37+
* - Applying OAS 3.2 default serialization rules (style/explode).
3738
*
3839
* @param swaggerPaths The raw `paths` object from the specification.
3940
* @param resolveRef Optional callback to resolve `$ref` pointers within path items.
4041
* @returns An array of normalized `PathInfo` objects ready for analysis.
4142
*/
4243
export function extractPaths(
4344
swaggerPaths: { [p: string]: PathItem } | undefined,
44-
resolveRef?: (ref: string) => PathItem | undefined
45+
resolveRef?: (ref: string) => PathItem | undefined,
46+
components?: { securitySchemes?: Record<string, any> } | undefined
4547
): PathInfo[] {
4648
if (!swaggerPaths) {
4749
return [];
@@ -50,6 +52,9 @@ export function extractPaths(
5052
const paths: PathInfo[] = [];
5153
const methods = ["get", "post", "put", "patch", "delete", "options", "head", "query"];
5254

55+
// Create a lookup Set for security schemes to enforce precedence rules (OAS 3.2 Security Requirements)
56+
const securitySchemeNames = new Set(components?.securitySchemes ? Object.keys(components.securitySchemes) : []);
57+
5358
for (const [path, rawPathItem] of Object.entries(swaggerPaths)) {
5459
let pathItem = rawPathItem;
5560

@@ -154,6 +159,21 @@ export function extractPaths(
154159
}
155160
}
156161

162+
// OAS 3.2 Serialization Defaults
163+
if (!param.style) {
164+
if (param.in === 'query' || param.in === 'cookie') {
165+
param.style = 'form';
166+
} else if (param.in === 'path' || param.in === 'header') {
167+
param.style = 'simple';
168+
}
169+
}
170+
171+
if (param.explode === undefined) {
172+
// "When style is form, the default value is true. For all other styles, the default value is false."
173+
// Note: This applies to cookie style='form' as well.
174+
param.explode = (param.style === 'form');
175+
}
176+
157177
if (p.required !== undefined) param.required = p.required;
158178
if (p.description) param.description = p.description;
159179

@@ -198,7 +218,14 @@ export function extractPaths(
198218
effectiveSecurity = effectiveSecurity.map(req => {
199219
const normalizedReq: { [key: string]: string[] } = {};
200220
Object.keys(req).forEach(key => {
201-
normalizedReq[normalizeSecurityKey(key)] = req[key];
221+
// OAS 3.2 Precedence: If key matches a component name exactly, assume it is a Component Name.
222+
// Otherwise, treat as URI reference (used for splitting last segment).
223+
// This prevents ambiguity when a Component Name looks like a URI (e.g. "http://auth")
224+
if (securitySchemeNames.has(key)) {
225+
normalizedReq[key] = req[key];
226+
} else {
227+
normalizedReq[normalizeSecurityKey(key)] = req[key];
228+
}
202229
});
203230
return normalizedReq;
204231
});
@@ -273,7 +300,7 @@ function path_to_method_name_suffix(path: string): string {
273300
*/
274301
export function groupPathsByController(parser: SwaggerParser): Record<string, PathInfo[]> {
275302
const usedMethodNames = new Set<string>();
276-
const allOperations = extractPaths(parser.getSpec().paths);
303+
const allOperations = parser.operations; // Use parser.operations which is already extracted
277304
const groups: Record<string, PathInfo[]> = {};
278305

279306
for (const operation of allOperations) {

0 commit comments

Comments
 (0)