Skip to content

feat: fixed typescript error dynamic paths #2106

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export { default as transformOperationObject } from "./transform/operation-objec
export { default as transformParameterObject } from "./transform/parameter-object.js";
export * from "./transform/path-item-object.js";
export { default as transformPathItemObject } from "./transform/path-item-object.js";
export { default as transformPathsObject } from "./transform/paths-object.js";
export * from "./transform/paths-object.js";
export { default as transformRequestBodyObject } from "./transform/request-body-object.js";
export { default as transformResponseObject } from "./transform/response-object.js";
export { default as transformResponsesObject } from "./transform/responses-object.js";
Expand Down
44 changes: 41 additions & 3 deletions packages/openapi-typescript/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NEVER, STRING, stringToAST, tsModifiers, tsRecord } from "../lib/ts.js"
import { createRef, debug } from "../lib/utils.js";
import type { GlobalContext, OpenAPI3 } from "../types.js";
import transformComponentsObject from "./components-object.js";
import transformPathsObject from "./paths-object.js";
import { transformDynamicPathsObject, transformPathsObject } from "./paths-object.js";
import transformSchemaObject from "./schema-object.js";
import transformWebhooksObject from "./webhooks-object.js";
import makeApiPathsEnum from "./paths-enum.js";
Expand All @@ -26,6 +26,18 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
type.push(...injectNodes);
}

// First create dynamicPaths if needed
if (ctx.pathParamsAsTypes && schema.paths) {
type.push(
ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ "dynamicPaths",
/* typeParameters */ undefined,
/* type */ transformDynamicPathsObject(schema.paths, ctx),
),
);
}

for (const root of Object.keys(transformers) as SchemaTransforms[]) {
const emptyObj = ts.factory.createTypeAliasDeclaration(
/* modifiers */ tsModifiers({ export: true }),
Expand All @@ -52,11 +64,37 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* heritageClauses */ undefined,
/* members */ (subType as TypeLiteralNode).members,
/* heritageClauses */ ctx.pathParamsAsTypes && root === "paths"
? [
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
ts.factory.createExpressionWithTypeArguments(
ts.factory.createIdentifier("dynamicPaths"),
undefined,
),
]),
]
: undefined,
/* members */ (subType as ts.TypeLiteralNode).members,
),
);
debug(`${root} done`, "ts", performance.now() - rootT);
} else if (root === "paths" && ctx.pathParamsAsTypes) {
type.push(
ts.factory.createInterfaceDeclaration(
/* modifiers */ tsModifiers({ export: true }),
/* name */ root,
/* typeParameters */ undefined,
/* heritageClauses */ [
ts.factory.createHeritageClause(ts.SyntaxKind.ExtendsKeyword, [
ts.factory.createExpressionWithTypeArguments(
ts.factory.createIdentifier("dynamicPaths"),
undefined,
),
]),
],
/* members */ (subType as ts.TypeLiteralNode).members,
),
);
} else {
type.push(emptyObj);
debug(`${root} done (skipped)`, "ts", 0);
Expand Down
154 changes: 104 additions & 50 deletions packages/openapi-typescript/src/transform/paths-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const PATH_PARAM_RE = /\{[^}]+\}/g;
* Transform the PathsObject node (4.8.8)
* @see https://spec.openapis.org/oas/v3.1.0#operation-object
*/
export default function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
export function transformPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
const type: ts.TypeElement[] = [];
for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) {
if (!pathItemObject || typeof pathItemObject !== "object") {
Expand All @@ -43,67 +43,121 @@ export default function transformPathsObject(pathsObject: PathsObject, ctx: Glob
ctx,
});

// pathParamsAsTypes
if (ctx.pathParamsAsTypes && url.includes("{")) {
const pathParams = extractPathParams(pathItemObject, ctx);
const matches = url.match(PATH_PARAM_RE);
let rawPath = `\`${url}\``;
if (matches) {
for (const match of matches) {
const paramName = match.slice(1, -1);
const param = pathParams[paramName];
switch (param?.schema?.type) {
case "number":
case "integer":
rawPath = rawPath.replace(match, "${number}");
break;
case "boolean":
rawPath = rawPath.replace(match, "${boolean}");
break;
default:
rawPath = rawPath.replace(match, "${string}");
break;
}
}
// note: creating a string template literal’s AST manually is hard!
// just pass an arbitrary string to TS
const pathType = (stringToAST(rawPath)[0] as any)?.expression;
if (pathType) {
type.push(
ts.factory.createIndexSignature(
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
/* parameters */ [
ts.factory.createParameterDeclaration(
/* modifiers */ undefined,
/* dotDotDotToken */ undefined,
/* name */ "path",
/* questionToken */ undefined,
/* type */ pathType,
/* initializer */ undefined,
),
],
/* type */ pathItemType,
),
);
continue;
}
if (!(ctx.pathParamsAsTypes && url.includes("{"))) {
type.push(
ts.factory.createPropertySignature(
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
/* name */ tsPropertyIndex(url),
/* questionToken */ undefined,
/* type */ pathItemType,
),
);
}

debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
}
}

return ts.factory.createTypeLiteralNode(type);
}

export function transformDynamicPathsObject(pathsObject: PathsObject, ctx: GlobalContext): ts.TypeNode {
if (!ctx.pathParamsAsTypes) {
return ts.factory.createTypeLiteralNode([]);
}

const types: ts.TypeNode[] = [];
for (const [url, pathItemObject] of getEntries(pathsObject, ctx)) {
if (!pathItemObject || typeof pathItemObject !== "object") {
continue;
}

if (!url.includes("{")) {
continue;
}
if ("$ref" in pathItemObject) {
continue;
}

const pathT = performance.now();

// handle $ref
const pathItemType = transformPathItemObject(pathItemObject, {
path: createRef(["paths", url]),
ctx,
});

// pathParamsAsTypes
const pathParams = extractPathParams(pathItemObject, ctx);
const matches = url.match(PATH_PARAM_RE);
let rawPath = `\`${url}\``;
if (matches) {
for (const match of matches) {
const paramName = match.slice(1, -1);
const param = pathParams[paramName];
switch (param?.schema?.type) {
case "number":
case "integer":
rawPath = rawPath.replace(match, "${number}");
break;
case "boolean":
rawPath = rawPath.replace(match, "${boolean}");
break;
default:
rawPath = rawPath.replace(match, "${string}");
break;
}
}
// note: creating a string template literal's AST manually is hard!
// just pass an arbitrary string to TS
const pathType = (stringToAST(rawPath)[0] as any)?.expression;
if (pathType) {
types.push(
ts.factory.createTypeLiteralNode([
ts.factory.createIndexSignature(
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
/* parameters */ [
ts.factory.createParameterDeclaration(
/* modifiers */ undefined,
/* dotDotDotToken */ undefined,
/* name */ "path",
/* questionToken */ undefined,
/* type */ pathType,
/* initializer */ undefined,
),
],
/* type */ pathItemType,
),
]),
);
continue;
}
}

type.push(
types.push(
ts.factory.createTypeLiteralNode([
ts.factory.createPropertySignature(
/* modifiers */ tsModifiers({ readonly: ctx.immutable }),
/* name */ tsPropertyIndex(url),
/* questionToken */ undefined,
/* type */ pathItemType,
),
);
]),
);

debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
}
debug(`Transformed path "${url}"`, "ts", performance.now() - pathT);
}

return ts.factory.createTypeLiteralNode(type);
// // Combine all types with intersection
// if (types.length === 0) {
// return ts.factory.createTypeLiteralNode([]);
// }

// if (types.length === 1) {
// return types[0];
// }

return ts.factory.createIntersectionTypeNode(types);
}

function extractPathParams(pathItemObject: PathItemObject, ctx: GlobalContext) {
Expand Down
91 changes: 90 additions & 1 deletion packages/openapi-typescript/test/node-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export type operations = Record<string, never>;`,
},
},
},
want: `export interface paths {
want: `export type dynamicPaths = {
[path: \`/user/\${string}\`]: {
parameters: {
query?: never;
Expand Down Expand Up @@ -329,6 +329,95 @@ export type operations = Record<string, never>;`,
patch?: never;
trace?: never;
};
};
export interface paths extends dynamicPaths {
}
export type webhooks = Record<string, never>;
export interface components {
schemas: never;
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;`,
options: { pathParamsAsTypes: true },
},
],
[
"options > pathParamsAsTypes > true simple and dynamic paths",
{
given: {
openapi: "3.1",
info: { title: "Test", version: "1.0" },
paths: {
"/users": {
get: [],
},
"/users/{user_id}": {
get: {
parameters: [{ name: "user_id", in: "path" }],
},
},
},
},
want: `export type dynamicPaths = {
[path: \`/users/\${string}\`]: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path: {
user_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: never;
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
};
export interface paths extends dynamicPaths {
"/users": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: never;
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
Expand Down
Loading
Loading