Skip to content

Commit abbea8c

Browse files
committed
feat: fixed typescript error dynamic paths
1 parent 7d6e896 commit abbea8c

File tree

6 files changed

+500
-73
lines changed

6 files changed

+500
-73
lines changed

packages/openapi-typescript/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export { default as transformOperationObject } from "./transform/operation-objec
1818
export { default as transformParameterObject } from "./transform/parameter-object.js";
1919
export * from "./transform/path-item-object.js";
2020
export { default as transformPathItemObject } from "./transform/path-item-object.js";
21-
export { default as transformPathsObject } from "./transform/paths-object.js";
21+
export * from "./transform/paths-object.js";
2222
export { default as transformRequestBodyObject } from "./transform/request-body-object.js";
2323
export { default as transformResponseObject } from "./transform/response-object.js";
2424
export { default as transformResponsesObject } from "./transform/responses-object.js";

packages/openapi-typescript/src/transform/index.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { NEVER, STRING, stringToAST, tsModifiers, tsRecord } from "../lib/ts.js"
44
import { createRef, debug } from "../lib/utils.js";
55
import type { GlobalContext, OpenAPI3 } from "../types.js";
66
import transformComponentsObject from "./components-object.js";
7-
import transformPathsObject from "./paths-object.js";
7+
import { transformDynamicPathsObject, transformPathsObject } from "./paths-object.js";
88
import transformSchemaObject from "./schema-object.js";
99
import transformWebhooksObject from "./webhooks-object.js";
1010
import makeApiPathsEnum from "./paths-enum.js";
@@ -26,6 +26,18 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
2626
type.push(...injectNodes);
2727
}
2828

29+
// First create dynamicPaths if needed
30+
if (ctx.pathParamsAsTypes && schema.paths) {
31+
type.push(
32+
ts.factory.createTypeAliasDeclaration(
33+
/* modifiers */ tsModifiers({ export: true }),
34+
/* name */ "dynamicPaths",
35+
/* typeParameters */ undefined,
36+
/* type */ transformDynamicPathsObject(schema.paths, ctx),
37+
),
38+
);
39+
}
40+
2941
for (const root of Object.keys(transformers) as SchemaTransforms[]) {
3042
const emptyObj = ts.factory.createTypeAliasDeclaration(
3143
/* modifiers */ tsModifiers({ export: true }),
@@ -52,11 +64,37 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
5264
/* modifiers */ tsModifiers({ export: true }),
5365
/* name */ root,
5466
/* typeParameters */ undefined,
55-
/* heritageClauses */ undefined,
56-
/* members */ (subType as TypeLiteralNode).members,
67+
/* heritageClauses */ ctx.pathParamsAsTypes && root === "paths"
68+
? [
69+
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
70+
ts.factory.createExpressionWithTypeArguments(
71+
ts.factory.createIdentifier("dynamicPaths"),
72+
undefined,
73+
),
74+
]),
75+
]
76+
: undefined,
77+
/* members */ (subType as ts.TypeLiteralNode).members,
5778
),
5879
);
5980
debug(`${root} done`, "ts", performance.now() - rootT);
81+
} else if (root === "paths" && ctx.pathParamsAsTypes) {
82+
type.push(
83+
ts.factory.createInterfaceDeclaration(
84+
/* modifiers */ tsModifiers({ export: true }),
85+
/* name */ root,
86+
/* typeParameters */ undefined,
87+
/* heritageClauses */ [
88+
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
89+
ts.factory.createExpressionWithTypeArguments(
90+
ts.factory.createIdentifier("dynamicPaths"),
91+
undefined,
92+
),
93+
]),
94+
],
95+
/* members */ (subType as ts.TypeLiteralNode).members,
96+
),
97+
);
6098
} else {
6199
type.push(emptyObj);
62100
debug(`${root} done (skipped)`, "ts", 0);

packages/openapi-typescript/src/transform/paths-object.ts

+104-50
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const PATH_PARAM_RE = /\{[^}]+\}/g;
1818
* Transform the PathsObject node (4.8.8)
1919
* @see https://spec.openapis.org/oas/v3.1.0#operation-object
2020
*/
21-
export default function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
21+
export function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
2222
const type: ts.TypeElement[] = [];
2323
for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) {
2424
if (!pathItemObject || typeof pathItemObject !== "object") {
@@ -43,67 +43,121 @@ export default function transformPathsObject(pathsObject: PathsObject, ctx: Glob
4343
ctx,
4444
});
4545

46-
// pathParamsAsTypes
47-
if (ctx.pathParamsAsTypes && url.includes("{")) {
48-
const pathParams = extractPathParams(pathItemObject, ctx);
49-
const matches = url.match(PATH_PARAM_RE);
50-
let rawPath = `\`${url}\``;
51-
if (matches) {
52-
for (const match of matches) {
53-
const paramName = match.slice(1, -1);
54-
const param = pathParams[paramName];
55-
switch (param?.schema?.type) {
56-
case "number":
57-
case "integer":
58-
rawPath = rawPath.replace(match, "${number}");
59-
break;
60-
case "boolean":
61-
rawPath = rawPath.replace(match, "${boolean}");
62-
break;
63-
default:
64-
rawPath = rawPath.replace(match, "${string}");
65-
break;
66-
}
67-
}
68-
// note: creating a string template literal’s AST manually is hard!
69-
// just pass an arbitrary string to TS
70-
const pathType = (stringToAST(rawPath)[0] as any)?.expression;
71-
if (pathType) {
72-
type.push(
73-
ts.factory.createIndexSignature(
74-
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
75-
/* parameters */ [
76-
ts.factory.createParameterDeclaration(
77-
/* modifiers */ undefined,
78-
/* dotDotDotToken */ undefined,
79-
/* name */ "path",
80-
/* questionToken */ undefined,
81-
/* type */ pathType,
82-
/* initializer */ undefined,
83-
),
84-
],
85-
/* type */ pathItemType,
86-
),
87-
);
88-
continue;
89-
}
46+
if (!(ctx.pathParamsAsTypes && url.includes("{"))) {
47+
type.push(
48+
ts.factory.createPropertySignature(
49+
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
50+
/* name */ tsPropertyIndex(url),
51+
/* questionToken */ undefined,
52+
/* type */ pathItemType,
53+
),
54+
);
55+
}
56+
57+
debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
58+
}
59+
}
60+
61+
return ts.factory.createTypeLiteralNode(type);
62+
}
63+
64+
export function transformDynamicPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
65+
if (!ctx.pathParamsAsTypes) {
66+
return ts.factory.createTypeLiteralNode([]);
67+
}
68+
69+
const types: ts.TypeNode[] = [];
70+
for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) {
71+
if (!pathItemObject || typeof pathItemObject !== "object") {
72+
continue;
73+
}
74+
75+
if (!url.includes("{")) {
76+
continue;
77+
}
78+
if ("$ref" in pathItemObject) {
79+
continue;
80+
}
81+
82+
const pathT = performance.now();
83+
84+
// handle $ref
85+
const pathItemType = transformPathItemObject(pathItemObject, {
86+
path: createRef(["paths", url]),
87+
ctx,
88+
});
89+
90+
// pathParamsAsTypes
91+
const pathParams = extractPathParams(pathItemObject, ctx);
92+
const matches = url.match(PATH_PARAM_RE);
93+
let rawPath = `\`${url}\``;
94+
if (matches) {
95+
for (const match of matches) {
96+
const paramName = match.slice(1, -1);
97+
const param = pathParams[paramName];
98+
switch (param?.schema?.type) {
99+
case "number":
100+
case "integer":
101+
rawPath = rawPath.replace(match, "${number}");
102+
break;
103+
case "boolean":
104+
rawPath = rawPath.replace(match, "${boolean}");
105+
break;
106+
default:
107+
rawPath = rawPath.replace(match, "${string}");
108+
break;
90109
}
91110
}
111+
// note: creating a string template literal's AST manually is hard!
112+
// just pass an arbitrary string to TS
113+
const pathType = (stringToAST(rawPath)[0] as any)?.expression;
114+
if (pathType) {
115+
types.push(
116+
ts.factory.createTypeLiteralNode([
117+
ts.factory.createIndexSignature(
118+
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
119+
/* parameters */ [
120+
ts.factory.createParameterDeclaration(
121+
/* modifiers */ undefined,
122+
/* dotDotDotToken */ undefined,
123+
/* name */ "path",
124+
/* questionToken */ undefined,
125+
/* type */ pathType,
126+
/* initializer */ undefined,
127+
),
128+
],
129+
/* type */ pathItemType,
130+
),
131+
]),
132+
);
133+
continue;
134+
}
135+
}
92136

93-
type.push(
137+
types.push(
138+
ts.factory.createTypeLiteralNode([
94139
ts.factory.createPropertySignature(
95140
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
96141
/* name */ tsPropertyIndex(url),
97142
/* questionToken */ undefined,
98143
/* type */ pathItemType,
99144
),
100-
);
145+
]),
146+
);
101147

102-
debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
103-
}
148+
debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
104149
}
105150

106-
return ts.factory.createTypeLiteralNode(type);
151+
// // Combine all types with intersection
152+
// if (types.length === 0) {
153+
// return ts.factory.createTypeLiteralNode([]);
154+
// }
155+
156+
// if (types.length === 1) {
157+
// return types[0];
158+
// }
159+
160+
return ts.factory.createIntersectionTypeNode(types);
107161
}
108162

109163
function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) {

packages/openapi-typescript/test/node-api.test.ts

+90-1
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ export type operations = Record<string, never>;`,
290290
},
291291
},
292292
},
293-
want: `export interface paths {
293+
want: `export type dynamicPaths = {
294294
[path: \`/user/\${string}\`]: {
295295
parameters: {
296296
query?: never;
@@ -329,6 +329,95 @@ export type operations = Record<string, never>;`,
329329
patch?: never;
330330
trace?: never;
331331
};
332+
};
333+
export interface paths extends dynamicPaths {
334+
}
335+
export type webhooks = Record<string, never>;
336+
export interface components {
337+
schemas: never;
338+
responses: never;
339+
parameters: never;
340+
requestBodies: never;
341+
headers: never;
342+
pathItems: never;
343+
}
344+
export type $defs = Record<string, never>;
345+
export type operations = Record<string, never>;`,
346+
options: { pathParamsAsTypes: true },
347+
},
348+
],
349+
[
350+
"options > pathParamsAsTypes > true simple and dynamic paths",
351+
{
352+
given: {
353+
openapi: "3.1",
354+
info: { title: "Test", version: "1.0" },
355+
paths: {
356+
"/users": {
357+
get: [],
358+
},
359+
"/users/{user_id}": {
360+
get: {
361+
parameters: [{ name: "user_id", in: "path" }],
362+
},
363+
},
364+
},
365+
},
366+
want: `export type dynamicPaths = {
367+
[path: \`/users/\${string}\`]: {
368+
parameters: {
369+
query?: never;
370+
header?: never;
371+
path?: never;
372+
cookie?: never;
373+
};
374+
get: {
375+
parameters: {
376+
query?: never;
377+
header?: never;
378+
path: {
379+
user_id: string;
380+
};
381+
cookie?: never;
382+
};
383+
requestBody?: never;
384+
responses: never;
385+
};
386+
put?: never;
387+
post?: never;
388+
delete?: never;
389+
options?: never;
390+
head?: never;
391+
patch?: never;
392+
trace?: never;
393+
};
394+
};
395+
export interface paths extends dynamicPaths {
396+
"/users": {
397+
parameters: {
398+
query?: never;
399+
header?: never;
400+
path?: never;
401+
cookie?: never;
402+
};
403+
get: {
404+
parameters: {
405+
query?: never;
406+
header?: never;
407+
path?: never;
408+
cookie?: never;
409+
};
410+
requestBody?: never;
411+
responses: never;
412+
};
413+
put?: never;
414+
post?: never;
415+
delete?: never;
416+
options?: never;
417+
head?: never;
418+
patch?: never;
419+
trace?: never;
420+
};
332421
}
333422
export type webhooks = Record<string, never>;
334423
export interface components {

0 commit comments

Comments
 (0)