Skip to content

Commit ea2aa56

Browse files
committed
Continue implementing OpenAPI 3.2.0
1 parent a240b75 commit ea2aa56

15 files changed

+615
-54
lines changed

src/core/parser.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface PolymorphicOption {
3636
/**
3737
* A wrapper class for a raw OpenAPI/Swagger specification object.
3838
* It provides a structured and reliable API to access different parts of the spec,
39-
* normalizing differences between Swagger 2.0 and OpenAPI 3.x and providing
39+
* normalizing differences between versions and providing
4040
* helpful utilities like `$ref` resolution.
4141
*/
4242
export class SwaggerParser {
@@ -51,6 +51,8 @@ export class SwaggerParser {
5151
public readonly schemas: { name: string; definition: SwaggerDefinition; }[];
5252
/** A flattened and processed list of all API operations (paths) from the entry specification. */
5353
public readonly operations: PathInfo[];
54+
/** A flattened and processed list of all Webhooks defined in the entry specification. */
55+
public readonly webhooks: PathInfo[];
5456
/** A normalized record of all security schemes defined in the entry specification. */
5557
public readonly security: Record<string, SecurityScheme>;
5658

@@ -78,12 +80,12 @@ export class SwaggerParser {
7880
// If a cache isn't provided, create one with just the entry spec.
7981
this.specCache = specCache || new Map<string, SwaggerSpec>([[this.documentUri, spec]]);
8082

81-
8283
this.schemas = Object.entries(this.getDefinitions()).map(([name, definition]) => ({
8384
name: pascalCase(name),
8485
definition
8586
}));
8687
this.operations = extractPaths(this.spec.paths);
88+
this.webhooks = extractPaths(this.spec.webhooks);
8789
this.security = this.getSecuritySchemes();
8890
}
8991

@@ -213,6 +215,11 @@ export class SwaggerParser {
213215
return this.spec;
214216
}
215217

218+
/** Retrieves the global JSON Schema Dialect if defined. */
219+
public getJsonSchemaDialect(): string | undefined {
220+
return this.spec.jsonSchemaDialect;
221+
}
222+
216223
/** Retrieves all schema definitions from the entry specification, normalizing for OpenAPI 3 and Swagger 2. */
217224
public getDefinitions(): Record<string, SwaggerDefinition> {
218225
return this.spec.definitions || this.spec.components?.schemas || {};

src/core/types.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,36 @@ export interface DiscriminatorObject {
2424
mapping?: { [key: string]: string };
2525
}
2626

27+
/**
28+
* Metadata object that allows for more fine-tuned XML model definitions.
29+
* @see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#xmlObject
30+
*/
31+
export interface XmlObject {
32+
/** Replaces the name of the element/attribute used for the described schema property. */
33+
name?: string;
34+
/** The URI of the namespace definition. */
35+
namespace?: string;
36+
/** The prefix to be used for the name. */
37+
prefix?: string;
38+
/** Declares whether the property definition translates to an attribute instead of an element. */
39+
attribute?: boolean;
40+
/** MAY be used only for an array definition. Signifies whether the array is wrapped. */
41+
wrapped?: boolean;
42+
/** Node type for XML mapping (OpenAPI 3.2.0 / Extended spec). */
43+
nodeType?: string;
44+
}
45+
46+
/**
47+
* Allows referencing an external resource for extended documentation.
48+
* @see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#externalDocumentationObject
49+
*/
50+
export interface ExternalDocumentationObject {
51+
/** A description of the target documentation. */
52+
description?: string;
53+
/** The URL for the target documentation. */
54+
url: string;
55+
}
56+
2757
/** A simplified, normalized representation of an operation parameter. */
2858
export interface Parameter {
2959
/** The name of the parameter. */
@@ -101,6 +131,8 @@ export interface SwaggerDefinition {
101131
format?: string;
102132
description?: string;
103133
default?: unknown;
134+
/** JSON Schema `const` keyword. (OAS 3.1 / JSON Schema 2020-12) */
135+
const?: unknown;
104136
maximum?: number;
105137
/** If true, the `maximum` is exclusive. */
106138
exclusiveMaximum?: boolean;
@@ -116,6 +148,16 @@ export interface SwaggerDefinition {
116148
multipleOf?: number;
117149
enum?: (string | number)[];
118150
items?: SwaggerDefinition | SwaggerDefinition[];
151+
152+
// JSON Schema 2020-12 / OpenAPI 3.1 Additions
153+
prefixItems?: SwaggerDefinition[];
154+
if?: SwaggerDefinition;
155+
then?: SwaggerDefinition;
156+
else?: SwaggerDefinition;
157+
not?: SwaggerDefinition;
158+
contentEncoding?: string;
159+
contentMediaType?: string;
160+
119161
$ref?: string;
120162
allOf?: SwaggerDefinition[];
121163
oneOf?: SwaggerDefinition[];
@@ -129,15 +171,19 @@ export interface SwaggerDefinition {
129171
required?: string[];
130172
/** An example of the schema representation. */
131173
example?: unknown;
174+
175+
xml?: XmlObject;
176+
externalDocs?: ExternalDocumentationObject;
132177
}
133178

134179
/** Represents a security scheme recognized by the API. */
135180
export interface SecurityScheme {
136-
type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect';
181+
type: 'apiKey' | 'http' | 'oauth2' | 'openIdConnect' | 'mutualTLS';
137182
in?: 'header' | 'query' | 'cookie';
138183
name?: string;
139184
scheme?: 'bearer' | string;
140185
flows?: Record<string, unknown>;
186+
openIdConnectUrl?: string;
141187
}
142188

143189
/** The root object of a parsed OpenAPI/Swagger specification. */
@@ -147,12 +193,17 @@ export interface SwaggerSpec {
147193
$self?: string;
148194
info: Info;
149195
paths: { [pathName: string]: Path };
196+
/** The incoming webhooks that MAY be received as part of this API. */
197+
webhooks?: { [name: string]: Path };
198+
/** The default value for the $schema keyword within Schema Objects. */
199+
jsonSchemaDialect?: string;
150200
/** Schema definitions (Swagger 2.0). */
151201
definitions?: { [definitionsName: string]: SwaggerDefinition };
152202
/** Replaces `definitions` in OpenAPI 3.x. */
153203
components?: {
154204
schemas?: Record<string, SwaggerDefinition>;
155205
securitySchemes?: Record<string, SecurityScheme>;
206+
pathItems?: Record<string, Path>;
156207
};
157208
/** Security definitions (Swagger 2.0). */
158209
securityDefinitions?: { [securityDefinitionName: string]: SecurityScheme };

src/core/utils.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
117117
const typeName = pascalCase(schema.$ref.split('/').pop() || '');
118118
return typeName && knownTypes.includes(typeName) ? typeName : 'any';
119119
}
120+
121+
// JSON Schema 'const' keyword support (OAS 3.1)
122+
if (schema.const !== undefined) {
123+
const val = schema.const;
124+
if (val === null) return 'null';
125+
if (typeof val === 'string') return `'${val.replace(/'/g, "\\'")}'`;
126+
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
127+
// For complex objects in const, fallback to 'any' or simple types is safer than partial object literals
128+
return 'any';
129+
}
130+
131+
// JSON Schema 2020-12 / OpenAPI 3.1 Tuple Support
132+
if (schema.prefixItems && Array.isArray(schema.prefixItems)) {
133+
const tupleTypes = schema.prefixItems.map(s => getTypeScriptType(s, config, knownTypes));
134+
// If `items` exists alongside prefixItems, it signifies the type for additional items (rest element)
135+
if (schema.items && !Array.isArray(schema.items)) {
136+
const restType = getTypeScriptType(schema.items as SwaggerDefinition, config, knownTypes);
137+
return `[${tupleTypes.join(', ')}, ...${restType}[]]`;
138+
}
139+
return `[${tupleTypes.join(', ')}]`;
140+
}
141+
120142
if (schema.allOf) {
121143
const parts = schema.allOf
122144
.map(s => getTypeScriptType(s, config, knownTypes))
@@ -131,6 +153,17 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
131153
return parts.length > 0 ? parts.join(' | ') : 'any';
132154
}
133155

156+
// Handling for 'not' keyword: Exclude<any, Type> or Exclude<KnownType, Type>
157+
if (schema.not) {
158+
const notType = getTypeScriptType(schema.not, config, knownTypes);
159+
/**
160+
* Note: Generating `Exclude<any, T>` in TS effectively just stays `any` or `unknown` depending on usage,
161+
* making strict `not` validation irrelevant at build time unless used within composite types.
162+
* We return a utility type string for clarity.
163+
*/
164+
return `Exclude<any, ${notType}>`;
165+
}
166+
134167
if (schema.enum) {
135168
return schema.enum.map(v => typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : v).join(' | ');
136169
}
@@ -140,7 +173,8 @@ export function getTypeScriptType(schema: SwaggerDefinition | undefined | null,
140173
switch (schema.type) {
141174
case 'string':
142175
type = (schema.format === 'date' || schema.format === 'date-time') && config.options.dateType === 'Date' ? 'Date' : 'string';
143-
if (schema.format === 'binary') {
176+
// OAS 3.1 support: contentMediaType='image/png' implies binary data -> Blob
177+
if (schema.format === 'binary' || schema.contentMediaType) {
144178
type = 'Blob';
145179
}
146180
break;

src/service/emit/service/service-method.generator.ts

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,6 @@ export class ServiceMethodGenerator {
8888

8989
(operation.parameters ?? []).forEach(param => {
9090
const paramName = camelCase(param.name);
91-
// The `extractPaths` function normalizes Swagger 2.0 parameters to always have a `schema` object.
9291
const paramType = getTypeScriptType(param.schema, this.config, knownTypes);
9392

9493
parameters.push({
@@ -116,22 +115,20 @@ export class ServiceMethodGenerator {
116115
private buildMethodBody(operation: PathInfo, parameters: OptionalKind<ParameterDeclarationStructure>[]): string {
117116
let urlTemplate = operation.path;
118117
operation.parameters?.filter(p => p.in === 'path').forEach(p => {
119-
urlTemplate = urlTemplate.replace(`{${p.name}}`, `\${${camelCase(p.name)}}`);
118+
const jsParam = camelCase(p.name);
119+
const style = p.style || 'simple';
120+
const explode = p.explode ?? false;
121+
urlTemplate = urlTemplate.replace(`{${p.name}}`, `\${HttpParamsBuilder.serializePathParam('${p.name}', ${jsParam}, '${style}', ${explode})}`);
120122
});
121123

122124
const lines: string[] = [];
123125

124-
const cookieParams = operation.parameters?.filter(p => p.in === 'cookie') ?? [];
125-
if (cookieParams.length > 0) {
126-
lines.push(`// TODO: Cookie parameters are not handled by Angular's HttpClient. You may need to handle them manually.
127-
console.warn('The following cookie parameters are not automatically handled:', ${JSON.stringify(cookieParams.map(p => p.name))});`);
128-
}
129-
130126
const querystringParams = operation.parameters?.filter(p => p.in === 'querystring') ?? [];
131127
if (querystringParams.length > 0) {
132-
lines.push(`// TODO: querystring parameters are not handled by Angular's HttpClient. You may need to handle them manually by constructing the URL.
128+
lines.push(`// TODO: querystring parameters are not handled by Angular's HttpClient. You may need to handle them manually by constructing the URL.
133129
console.warn('The following querystring parameters are not automatically handled:', ${JSON.stringify(querystringParams.map(p => p.name))});`);
134130
}
131+
135132
lines.push(`const url = \`\${this.basePath}${urlTemplate}\`;`);
136133

137134
const requestOptions: HttpRequestOptions = {};
@@ -144,17 +141,33 @@ console.warn('The following querystring parameters are not automatically handled
144141
const paramDefJson = JSON.stringify(p);
145142
lines.push(`if (${paramName} != null) { params = HttpParamsBuilder.serializeQueryParam(params, ${paramDefJson}, ${paramName}); }`);
146143
});
147-
requestOptions.params = 'params' as any; // Use string placeholder
144+
requestOptions.params = 'params' as any;
148145
}
149146

150147
const headerParams = operation.parameters?.filter(p => p.in === 'header') ?? [];
151-
if (headerParams.length > 0) {
148+
const cookieParams = operation.parameters?.filter(p => p.in === 'cookie') ?? [];
149+
150+
if (headerParams.length > 0 || cookieParams.length > 0) {
152151
lines.push(`let headers = options?.headers instanceof HttpHeaders ? options.headers : new HttpHeaders(options?.headers ?? {});`);
152+
153153
headerParams.forEach(p => {
154154
const paramName = camelCase(p.name);
155-
lines.push(`if (${paramName} != null) { headers = headers.set('${p.name}', String(${paramName})); }`);
155+
const explode = p.explode ?? false;
156+
lines.push(`if (${paramName} != null) { headers = headers.set('${p.name}', HttpParamsBuilder.serializeHeaderParam('${p.name}', ${paramName}, ${explode})); }`);
156157
});
157-
requestOptions.headers = 'headers' as any; // Use string placeholder
158+
159+
if (cookieParams.length > 0) {
160+
lines.push(`const __cookies: string[] = [];`);
161+
cookieParams.forEach(p => {
162+
const paramName = camelCase(p.name);
163+
const style = p.style || 'form';
164+
const explode = p.explode ?? true;
165+
lines.push(`if (${paramName} != null) { __cookies.push(HttpParamsBuilder.serializeCookieParam('${p.name}', ${paramName}, '${style}', ${explode})); }`);
166+
});
167+
lines.push(`if (__cookies.length > 0) { headers = headers.set('Cookie', __cookies.join('; ')); }`);
168+
}
169+
170+
requestOptions.headers = 'headers' as any;
158171
}
159172

160173
let optionProperties = `
@@ -244,7 +257,6 @@ console.warn('The following querystring parameters are not automatically handled
244257
].map(overload => {
245258
const hasOptionalParam = parameters.some(p => p.hasQuestionToken);
246259
if (hasOptionalParam) {
247-
// This is guaranteed to exist in our templates.
248260
overload.parameters.find(p => p.name === 'options')!.hasQuestionToken = true;
249261
}
250262
return overload;

src/service/emit/type/type.generator.ts

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,31 @@ export class TypeGenerator {
112112

113113
// Generate properties for the interface.
114114
if (definition.properties) {
115-
interfaceDeclaration.addProperties(Object.entries(definition.properties).map(([key, propDef]) => ({
116-
// Quote property names that are not valid TS identifiers (e.g., 'with-hyphen').
117-
name: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`,
118-
type: getTypeScriptType(propDef, this.config, knownTypes),
119-
hasQuestionToken: !(definition.required || []).includes(key),
120-
docs: propDef.description ? [propDef.description] : [],
121-
})));
115+
interfaceDeclaration.addProperties(Object.entries(definition.properties).map(([key, propDef]) => {
116+
117+
// Build property documentation including specific OpenAPI 3.1 fields
118+
const propDocs: string[] = [];
119+
if (propDef.description) {
120+
propDocs.push(propDef.description);
121+
}
122+
if (propDef.contentEncoding) {
123+
propDocs.push(`Content Encoding: ${propDef.contentEncoding}`);
124+
}
125+
if (propDef.contentMediaType) {
126+
propDocs.push(`Content Media Type: ${propDef.contentMediaType}`);
127+
}
128+
if (propDef.externalDocs?.url) {
129+
propDocs.push(`@see ${propDef.externalDocs.url} ${propDef.externalDocs.description || ''}`);
130+
}
131+
132+
return {
133+
// Quote property names that are not valid TS identifiers (e.g., 'with-hyphen').
134+
name: /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : `'${key}'`,
135+
type: getTypeScriptType(propDef, this.config, knownTypes),
136+
hasQuestionToken: !(definition.required || []).includes(key),
137+
docs: propDocs,
138+
};
139+
}));
122140
}
123141

124142
// Add an index signature if `additionalProperties` is defined.

src/service/emit/utility/auth-interceptor.generator.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import * as path from 'node:path';
33
import { Project, Scope } from 'ts-morph';
44
import { SwaggerParser } from '../../../core/parser.js';
55
import { UTILITY_GENERATOR_HEADER_COMMENT } from '../../../core/constants.js';
6+
import { SecurityScheme } from '../../../core/types.js';
67

78
/**
89
* Generates the `auth.interceptor.ts` file. This interceptor is responsible for
910
* attaching API keys and/or Bearer tokens to outgoing HTTP requests based on the
1011
* security schemes defined in the OpenAPI specification.
11-
* It currently supports `apiKey` (in header or query) and `http`/`oauth2` (for Bearer tokens).
12-
* Other schemes like `apiKey` in `cookie` are parsed but do not generate interception logic.
12+
* It currently supports `apiKey` (in header or query), `http` (bearer), `oauth2`, and `openIdConnect`.
1313
*/
1414
export class AuthInterceptorGenerator {
1515
/**
@@ -21,7 +21,7 @@ export class AuthInterceptorGenerator {
2121

2222
/**
2323
* Generates the auth interceptor file if any **supported** security schemes are defined in the spec.
24-
* A scheme is supported if it's an `apiKey` in the header/query or an `http`/`oauth2` bearer token.
24+
* A scheme is supported if it's an `apiKey` in the header/query or an `http`/`oauth2`/`openIdConnect` (Bearer).
2525
*
2626
* @param outputDir The root output directory.
2727
* @returns An object containing the names of the tokens for supported schemes (e.g., `['apiKey', 'bearerToken']`),
@@ -31,7 +31,7 @@ export class AuthInterceptorGenerator {
3131
const securitySchemes = Object.values(this.parser.getSecuritySchemes());
3232

3333
const hasSupportedApiKey = securitySchemes.some(s => s.type === 'apiKey' && (s.in === 'header' || s.in === 'query'));
34-
const hasBearer = securitySchemes.some(s => (s.type === 'http' && s.scheme === 'bearer') || s.type === 'oauth2');
34+
const hasBearer = securitySchemes.some(s => this.isBearerScheme(s));
3535

3636
// If no supported schemes are found, do not generate the file at all.
3737
if (!hasSupportedApiKey && !hasBearer) {
@@ -96,7 +96,8 @@ export class AuthInterceptorGenerator {
9696
let statementsBody = 'let authReq = req;';
9797
let bearerLogicAdded = false;
9898

99-
const uniqueSchemes = Array.from(new Set(securitySchemes.map(s => JSON.stringify(s)))).map(s => JSON.parse(s));
99+
// De-duplicate schemes for generation loop
100+
const uniqueSchemes = Array.from(new Set(securitySchemes.map(s => JSON.stringify(s)))).map(s => JSON.parse(s) as SecurityScheme);
100101

101102
for (const scheme of uniqueSchemes) {
102103
if (scheme.type === 'apiKey' && scheme.name) {
@@ -105,7 +106,7 @@ export class AuthInterceptorGenerator {
105106
} else if (scheme.in === 'query') {
106107
statementsBody += `\nif (this.apiKey) { authReq = authReq.clone({ setParams: { ...authReq.params.keys().reduce((acc, key) => ({ ...acc, [key]: authReq.params.getAll(key) }), {}), '${scheme.name}': this.apiKey } }); }`;
107108
}
108-
} else if ((scheme.type === 'http' && scheme.scheme === 'bearer') || scheme.type === 'oauth2') {
109+
} else if (this.isBearerScheme(scheme)) {
109110
if (!bearerLogicAdded) {
110111
statementsBody += `\nif (this.bearerToken) { const token = typeof this.bearerToken === 'function' ? this.bearerToken() : this.bearerToken; if (token) { authReq = authReq.clone({ setHeaders: { ...authReq.headers.keys().reduce((acc, key) => ({ ...acc, [key]: authReq.headers.getAll(key) }), {}), 'Authorization': \`Bearer \${token}\` } }); } }`;
111112
bearerLogicAdded = true;
@@ -128,4 +129,8 @@ export class AuthInterceptorGenerator {
128129
sourceFile.formatText();
129130
return { tokenNames };
130131
}
132+
133+
private isBearerScheme(s: SecurityScheme): boolean {
134+
return (s.type === 'http' && s.scheme === 'bearer') || s.type === 'oauth2' || s.type === 'openIdConnect';
135+
}
131136
}

0 commit comments

Comments
 (0)