Skip to content

Commit 89c1d7d

Browse files
committed
Continue implementing OpenAPI 3.2.0
1 parent 4bea470 commit 89c1d7d

33 files changed

+2273
-434
lines changed

src/core/parser.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
* parsing, and providing a unified interface to OpenAPI (3.x) and Swagger (2.x) specifications.
55
*/
66

7-
import { GeneratorConfig, PathInfo, SecurityScheme, ServerObject, SwaggerDefinition, SwaggerSpec } from './types.js';
7+
import { GeneratorConfig, PathInfo, SecurityScheme, ServerObject, SwaggerDefinition, SwaggerSpec, LinkObject } from './types.js';
88
import * as fs from 'node:fs';
99
import * as path from 'node:path';
1010
import { pathToFileURL } from 'node:url';
1111
import yaml from 'js-yaml';
1212
import { extractPaths, isUrl, pascalCase } from './utils.js';
13+
import { validateSpec } from './validator.js';
1314

1415
/** Represents a `$ref` object in a JSON Schema. */
1516
interface RefObject {
@@ -59,6 +60,8 @@ export class SwaggerParser {
5960
public readonly webhooks: PathInfo[];
6061
/** A normalized record of all security schemes defined in the entry specification. */
6162
public readonly security: Record<string, SecurityScheme>;
63+
/** A normalized record of all reusable links defined in the entry specification. */
64+
public readonly links: Record<string, LinkObject>;
6265

6366
/** A cache of all loaded specifications, keyed by their absolute URI. */
6467
private readonly specCache: Map<string, SwaggerSpec>;
@@ -77,6 +80,14 @@ export class SwaggerParser {
7780
specCache?: Map<string, SwaggerSpec>,
7881
documentUri: string = 'file://entry-spec.json'
7982
) {
83+
// 1. Fundamental Structure Validation
84+
validateSpec(spec);
85+
86+
// 2. User-Configured Custom Validation
87+
if (config.validateInput && !config.validateInput(spec)) {
88+
throw new Error("Custom input validation failed.");
89+
}
90+
8091
this.spec = spec;
8192
this.config = config;
8293
this.documentUri = documentUri;
@@ -93,6 +104,7 @@ export class SwaggerParser {
93104
this.operations = extractPaths(this.spec.paths);
94105
this.webhooks = extractPaths(this.spec.webhooks);
95106
this.security = this.getSecuritySchemes();
107+
this.links = this.getLinks();
96108
}
97109

98110
/**
@@ -241,6 +253,21 @@ export class SwaggerParser {
241253
return (this.spec.components?.securitySchemes || this.spec.securityDefinitions || {}) as Record<string, SecurityScheme>;
242254
}
243255

256+
/** Retrieves all reusable link definitions from the entry specification. */
257+
public getLinks(): Record<string, LinkObject> {
258+
if (!this.spec.components?.links) return {};
259+
const links: Record<string, LinkObject> = {};
260+
for (const [key, val] of Object.entries(this.spec.components.links)) {
261+
if ('$ref' in val) {
262+
const resolved = this.resolveReference<LinkObject>(val.$ref);
263+
if (resolved) links[key] = resolved;
264+
} else {
265+
links[key] = val as LinkObject;
266+
}
267+
}
268+
return links;
269+
}
270+
244271
/**
245272
* Synchronously resolves a JSON reference (`$ref`) object to its corresponding definition.
246273
* If the provided object is not a `$ref`, it is returned as is.

src/core/runtime-expressions.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* @fileoverview
3+
* Implements the OpenAPI Runtime Expression evaluator defined in OAS 3.x.
4+
* Used for dynamically deriving values for Links and Callbacks from HTTP messages.
5+
*
6+
* @see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.2.0.md#runtime-expressions
7+
*/
8+
9+
/**
10+
* Context required to evaluate a runtime expression.
11+
* Represents the state of the HTTP interaction (Request/Response).
12+
*/
13+
export interface RuntimeContext {
14+
url: string;
15+
method: string;
16+
statusCode: number;
17+
request: {
18+
headers: Record<string, string | string[] | undefined>;
19+
query: Record<string, string | string[] | undefined>;
20+
path: Record<string, string | undefined>;
21+
body?: any;
22+
};
23+
response?: {
24+
headers: Record<string, string | string[] | undefined>;
25+
body?: any;
26+
};
27+
}
28+
29+
/**
30+
* Resolves a JSON Pointer (RFC 6901) against a target object.
31+
* Used internally for processing `$request.body#/foo` style expressions.
32+
*
33+
* @param data The target object (body).
34+
* @param pointer The JSON pointer string (e.g., "/user/0/id").
35+
* @returns The resolved value or undefined if not found.
36+
*/
37+
export function evaluateJsonPointer(data: any, pointer: string): any {
38+
if (pointer === '' || pointer === '#') return data;
39+
40+
// Remove leading # if present (URI fragment style)
41+
const cleanPointer = pointer.startsWith('#') ? pointer.substring(1) : pointer;
42+
43+
if (!cleanPointer.startsWith('/')) return undefined;
44+
45+
const tokens = cleanPointer.split('/').slice(1).map(token =>
46+
token.replace(/~1/g, '/').replace(/~0/g, '~')
47+
);
48+
49+
let current = data;
50+
for (const token of tokens) {
51+
if (current === null || typeof current !== 'object') {
52+
return undefined;
53+
}
54+
// Arrays handling: standard JSON pointer can access array indices
55+
if (Array.isArray(current)) {
56+
if (!/^\d+$/.test(token)) return undefined;
57+
const index = parseInt(token, 10);
58+
if (index < 0 || index >= current.length) {
59+
return undefined;
60+
}
61+
current = current[index];
62+
} else {
63+
if (!(token in current)) {
64+
return undefined;
65+
}
66+
current = current[token];
67+
}
68+
}
69+
return current;
70+
}
71+
72+
/**
73+
* Helper to extract a header value case-insensitively (RFC 7230).
74+
*/
75+
function getHeader(headers: Record<string, string | string[] | undefined>, key: string): string | undefined {
76+
const lowerKey = key.toLowerCase();
77+
const foundKey = Object.keys(headers).find(k => k.toLowerCase() === lowerKey);
78+
if (!foundKey) return undefined;
79+
const val = headers[foundKey];
80+
return Array.isArray(val) ? val[0] : val;
81+
}
82+
83+
/**
84+
* Helper to extract a query parameter (Case-sensitive).
85+
*/
86+
function getQuery(query: Record<string, string | string[] | undefined>, key: string): string | undefined {
87+
const val = query[key];
88+
return Array.isArray(val) ? val[0] : val;
89+
}
90+
91+
/**
92+
* Resolves a single, bare runtime expression (e.g. "$request.body#/id").
93+
* Preserves the type of the referenced value (e.g. boolean, number, object).
94+
*/
95+
function resolveSingleExpression(expr: string, context: RuntimeContext): any {
96+
if (expr === '$url') return context.url;
97+
if (expr === '$method') return context.method;
98+
if (expr === '$statusCode') return context.statusCode;
99+
100+
if (expr.startsWith('$request.')) {
101+
const part = expr.substring(9); // remove "$request."
102+
if (part.startsWith('header.')) {
103+
return getHeader(context.request.headers, part.substring(7));
104+
}
105+
if (part.startsWith('query.')) {
106+
return getQuery(context.request.query, part.substring(6));
107+
}
108+
if (part.startsWith('path.')) {
109+
return context.request.path[part.substring(5)];
110+
}
111+
if (part.startsWith('body')) {
112+
if (part === 'body') return context.request.body;
113+
if (part.startsWith('body#')) {
114+
return evaluateJsonPointer(context.request.body, part.substring(5));
115+
}
116+
}
117+
}
118+
119+
if (expr.startsWith('$response.')) {
120+
if (!context.response) return undefined;
121+
const part = expr.substring(10); // remove "$response."
122+
if (part.startsWith('header.')) {
123+
return getHeader(context.response.headers, part.substring(7));
124+
}
125+
if (part.startsWith('body')) {
126+
if (part === 'body') return context.response.body;
127+
if (part.startsWith('body#')) {
128+
return evaluateJsonPointer(context.response.body, part.substring(5));
129+
}
130+
}
131+
}
132+
133+
return undefined;
134+
}
135+
136+
/**
137+
* Evaluates a runtime expression against a given connection context.
138+
*
139+
* Supports:
140+
* 1. Direct expressions: "$request.query.id" -> returns the value (preserving type).
141+
* 2. Embedded string expressions: "https://example.com/{$request.path.id}" -> returns interpolated string.
142+
*
143+
* @param expression The expression string defined in the OpenAPI Link or Callback.
144+
* @param context The runtime data (request info, response info).
145+
* @returns The evaluated result.
146+
*/
147+
export function evaluateRuntimeExpression(expression: string, context: RuntimeContext): any {
148+
const hasBraces = expression.includes('{') && expression.includes('}');
149+
150+
// Case 1: Bare expression (must start with $)
151+
if (expression.startsWith('$') && !hasBraces) {
152+
return resolveSingleExpression(expression, context);
153+
}
154+
155+
// Case 2: Constant string (no braces, no $)
156+
if (!expression.includes('{')) {
157+
return expression;
158+
}
159+
160+
// Case 3: Embedded template string (e.g., "foo/{$url}/bar")
161+
return expression.replace(/\{([^}]+)\}/g, (_, innerExpr) => {
162+
const trimmed = innerExpr.trim();
163+
// Only interpolate if it looks like a variable we recognize, otherwise leave it?
164+
// OAS implies logical expressions inside braces.
165+
const val = resolveSingleExpression(trimmed, context);
166+
return val !== undefined ? String(val) : '';
167+
});
168+
}

src/core/types.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,47 @@ export interface ExternalDocumentationObject {
128128
url: string;
129129
}
130130

131+
/**
132+
* The Link Object represents a possible design-time link for a response.
133+
* @see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#linkObject
134+
*/
135+
export interface LinkObject {
136+
/** A URI reference to an OAS operation. Mutually exclusive with operationId. */
137+
operationRef?: string;
138+
/** The name of an existing, resolvable OAS operation. Mutually exclusive with operationRef. */
139+
operationId?: string;
140+
/** A map representing parameters to pass to an operation. Keys are param names, values are expressions or constants. */
141+
parameters?: { [name: string]: any | string; };
142+
/** A literal value or expression to use as a request body when calling the target operation. */
143+
requestBody?: any | string;
144+
/** A description of the link. */
145+
description?: string;
146+
/** A server object to be used by the target operation. */
147+
server?: ServerObject;
148+
}
149+
150+
/**
151+
* The Header Object follows the structure of the Parameter Object with the following changes:
152+
* 1. `name` MUST NOT be specified, it is given in the corresponding `headers` map.
153+
* 2. `in` MUST NOT be specified, it is implicitly in `header`.
154+
* @see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#headerObject
155+
*/
156+
export interface HeaderObject {
157+
description?: string;
158+
required?: boolean;
159+
deprecated?: boolean;
160+
schema?: SwaggerDefinition | { $ref: string };
161+
type?: 'string' | 'number' | 'integer' | 'boolean' | 'array';
162+
format?: string;
163+
items?: SwaggerDefinition | { $ref: string };
164+
style?: string;
165+
explode?: boolean;
166+
allowReserved?: boolean;
167+
content?: Record<string, { schema?: SwaggerDefinition | { $ref: string } }>;
168+
example?: any;
169+
examples?: Record<string, any>;
170+
}
171+
131172
/** A simplified, normalized representation of an operation parameter. */
132173
export interface Parameter {
133174
/** The name of the parameter. */
@@ -157,6 +198,8 @@ export interface Parameter {
157198
allowEmptyValue?: boolean;
158199
/** A map containing the representations for the parameter. For complex serialization scenarios. */
159200
content?: Record<string, { schema?: SwaggerDefinition | { $ref: string } }>;
201+
/** Specifies that a parameter is deprecated and SHOULD be transitioned out of usage. */
202+
deprecated?: boolean;
160203
}
161204

162205
/** A processed, unified representation of a single API operation (e.g., GET /users/{id}). */
@@ -171,6 +214,11 @@ export interface PathInfo {
171214
summary?: string;
172215
/** A verbose explanation of the operation behavior. */
173216
description?: string;
217+
/**
218+
* Declares this operation to be deprecated.
219+
* Consumers SHOULD refrain from usage of the declared operation.
220+
*/
221+
deprecated?: boolean;
174222
/** External documentation link. */
175223
externalDocs?: ExternalDocumentationObject;
176224
/** A list of tags for API documentation control. */
@@ -187,6 +235,10 @@ export interface PathInfo {
187235
methodName?: string;
188236
/** Security requirements specific to this operation. keys are definitions, values are scopes. */
189237
security?: { [key: string]: string[] }[];
238+
/** An alternate server array to service this operation. (OAS 3+) */
239+
servers?: ServerObject[];
240+
/** A map of possible out-of band callbacks related to the parent operation. (OAS 3+) */
241+
callbacks?: Record<string, PathItem | { $ref: string }>;
190242
}
191243

192244
/** A single encoding definition for a multipart property. */
@@ -221,6 +273,10 @@ export interface SwaggerResponse {
221273
description?: string;
222274
/** A map of media types to their corresponding schemas for the response. */
223275
content?: Record<string, { schema?: SwaggerDefinition | { $ref: string } }>;
276+
/** A map of operations links that can be followed from the response. */
277+
links?: Record<string, LinkObject | { $ref: string }>;
278+
/** Maps a header name to its definition. */
279+
headers?: Record<string, HeaderObject | { $ref: string }>;
224280
}
225281

226282
/**
@@ -265,6 +321,8 @@ export interface SwaggerDefinition {
265321
anyOf?: SwaggerDefinition[];
266322
additionalProperties?: SwaggerDefinition | boolean;
267323
properties?: { [propertyName: string]: SwaggerDefinition };
324+
/** A map of regex patterns to schemas for properties key validation. */
325+
patternProperties?: { [pattern: string]: SwaggerDefinition };
268326
discriminator?: DiscriminatorObject;
269327
readOnly?: boolean;
270328
writeOnly?: boolean;
@@ -304,6 +362,8 @@ export interface SpecOperation {
304362
schemes?: string[];
305363
deprecated?: boolean;
306364
security?: Record<string, string[]>[];
365+
servers?: ServerObject[]; // OAS 3 (Operation-level override)
366+
callbacks?: Record<string, PathItem | { $ref: string }>; // OAS 3
307367
[key: string]: any;
308368
}
309369

@@ -324,6 +384,7 @@ export interface PathItem {
324384
trace?: SpecOperation;
325385
query?: SpecOperation; // OAS 3.2 draft
326386
parameters?: any[];
387+
servers?: ServerObject[]; // OAS 3 (Path-level override)
327388
[key: string]: any;
328389
}
329390

@@ -347,6 +408,8 @@ export interface SwaggerSpec {
347408
schemas?: Record<string, SwaggerDefinition>;
348409
securitySchemes?: Record<string, SecurityScheme>;
349410
pathItems?: Record<string, PathItem>;
411+
links?: Record<string, LinkObject | { $ref: string }>;
412+
headers?: Record<string, HeaderObject | { $ref: string }>;
350413
};
351414
/** Security definitions (Swagger 2.0). */
352415
securityDefinitions?: { [securityDefinitionName: string]: SecurityScheme };

0 commit comments

Comments
 (0)