From abbea8cea748b4bfbcf3418b2b4e976587d97058 Mon Sep 17 00:00:00 2001 From: walid-mos Date: Mon, 20 Jan 2025 21:28:04 +0100 Subject: [PATCH] feat: fixed typescript error dynamic paths --- packages/openapi-typescript/src/index.ts | 2 +- .../openapi-typescript/src/transform/index.ts | 44 ++- .../src/transform/paths-object.ts | 154 ++++++---- .../openapi-typescript/test/node-api.test.ts | 91 +++++- .../test/transform/paths-object.test.ts | 279 ++++++++++++++++-- turbo.json | 3 +- 6 files changed, 500 insertions(+), 73 deletions(-) diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index f1b739496..4a53473a8 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -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"; diff --git a/packages/openapi-typescript/src/transform/index.ts b/packages/openapi-typescript/src/transform/index.ts index 1e02d356d..0bd5088d9 100644 --- a/packages/openapi-typescript/src/transform/index.ts +++ b/packages/openapi-typescript/src/transform/index.ts @@ -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"; @@ -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 }), @@ -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); diff --git a/packages/openapi-typescript/src/transform/paths-object.ts b/packages/openapi-typescript/src/transform/paths-object.ts index 0ac966509..2b74c0a4a 100644 --- a/packages/openapi-typescript/src/transform/paths-object.ts +++ b/packages/openapi-typescript/src/transform/paths-object.ts @@ -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") { @@ -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) { diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 2ad04f7aa..7165eb408 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -290,7 +290,7 @@ export type operations = Record;`, }, }, }, - want: `export interface paths { + want: `export type dynamicPaths = { [path: \`/user/\${string}\`]: { parameters: { query?: never; @@ -329,6 +329,95 @@ export type operations = Record;`, patch?: never; trace?: never; }; +}; +export interface paths extends dynamicPaths { +} +export type webhooks = Record; +export interface components { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + 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; export interface components { diff --git a/packages/openapi-typescript/test/transform/paths-object.test.ts b/packages/openapi-typescript/test/transform/paths-object.test.ts index d7be4f5c8..f657ac986 100644 --- a/packages/openapi-typescript/test/transform/paths-object.test.ts +++ b/packages/openapi-typescript/test/transform/paths-object.test.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from "node:url"; import { astToString } from "../../src/lib/ts.js"; -import transformPathsObject from "../../src/transform/paths-object.js"; +import { transformPathsObject, transformDynamicPathsObject } from "../../src/transform/paths-object.js"; import type { GlobalContext } from "../../src/types.js"; import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; @@ -319,28 +319,112 @@ describe("transformPathsObject", () => { }, }, }, + want: "{}", + options: { ...DEFAULT_OPTIONS, pathParamsAsTypes: true }, + }, + ], + ]; + + for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { + test.skipIf(ci?.skipIf)( + testName, + async () => { + const result = astToString(transformPathsObject(given, options)); + if (want instanceof URL) { + expect(result).toMatchFileSnapshot(fileURLToPath(want)); + } else { + expect(result).toBe(`${want}\n`); + } + }, + ci?.timeout, + ); + } +}); + +describe("transformDynamicPathsObject", () => { + const tests: TestCase[] = [ + [ + "basic path parameters with different types", + { + given: { + "/api/v1/users/{userId}": { + parameters: [ + { + name: "userId", + in: "path", + schema: { type: "integer" }, + description: "User ID.", + }, + ], + get: { + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { type: "integer" }, + name: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + "/api/v1/items/{itemId}/tags/{tagName}": { + parameters: [ + { + name: "itemId", + in: "path", + schema: { type: "integer" }, + description: "Item ID.", + }, + { + name: "tagName", + in: "path", + schema: { type: "string" }, + description: "Tag name.", + }, + ], + get: { + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { + type: "array", + items: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, want: `{ - [path: \`/api/v1/user/\${number}\`]: { + [path: \`/api/v1/users/\${number}\`]: { parameters: { - query?: { - /** @description Page number. */ - page?: number; - }; + query?: never; header?: never; path: { - user_id: number; + /** @description User ID. */ + userId: number; }; cookie?: never; }; get: { parameters: { - query?: { - /** @description Page number. */ - page?: number; - }; + query?: never; header?: never; path: { - user_id: number; + /** @description User ID. */ + userId: number; }; cookie?: never; }; @@ -349,18 +433,61 @@ describe("transformPathsObject", () => { /** @description OK */ 200: { headers: { - Link: components["headers"]["link"]; [name: string]: unknown; }; content: { "application/json": { - id: string; - email: string; + id?: number; name?: string; }; }; }; - 404: components["responses"]["NotFound"]; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} & { + [path: \`/api/v1/items/\${number}/tags/\${string}\`]: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Item ID. */ + itemId: number; + /** @description Tag name. */ + tagName: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Item ID. */ + itemId: number; + /** @description Tag name. */ + tagName: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string[]; + }; + }; }; }; put?: never; @@ -375,13 +502,131 @@ describe("transformPathsObject", () => { options: { ...DEFAULT_OPTIONS, pathParamsAsTypes: true }, }, ], + [ + "path parameters with $ref", + { + given: { + "/api/v1/users/{userId}/posts/{postId}": { + parameters: [{ $ref: "#/components/parameters/userId" }, { $ref: "#/components/parameters/postId" }], + get: { + responses: { + 200: { + description: "OK", + content: { + "application/json": { + schema: { + type: "object", + properties: { + title: { type: "string" }, + content: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: `{ + [path: \`/api/v1/users/\${number}/posts/\${string}\`]: { + parameters: { + query?: never; + header?: never; + path: { + userId: components["parameters"]["userId"]; + postId: components["parameters"]["postId"]; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + userId: components["parameters"]["userId"]; + postId: components["parameters"]["postId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + title?: string; + content?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}`, + options: { + ...DEFAULT_OPTIONS, + pathParamsAsTypes: true, + resolve($ref) { + switch ($ref) { + case "#/components/parameters/userId": { + return { + in: "path", + name: "userId", + schema: { type: "integer" }, + }; + } + case "#/components/parameters/postId": { + return { + in: "path", + name: "postId", + schema: { type: "string" }, + }; + } + default: { + return undefined as any; + } + } + }, + }, + }, + ], + [ + "pathParamsAsTypes: false", + { + given: { + "/api/v1/users/{userId}": { + parameters: [ + { + name: "userId", + in: "path", + schema: { type: "integer" }, + description: "User ID.", + }, + ], + }, + }, + want: "{}", + options: { ...DEFAULT_OPTIONS, pathParamsAsTypes: false }, + }, + ], ]; for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { test.skipIf(ci?.skipIf)( testName, async () => { - const result = astToString(transformPathsObject(given, options)); + const result = astToString(transformDynamicPathsObject(given, options)); if (want instanceof URL) { expect(result).toMatchFileSnapshot(fileURLToPath(want)); } else { diff --git a/turbo.json b/turbo.json index 86c9a3ad7..e8c7040df 100644 --- a/turbo.json +++ b/turbo.json @@ -2,7 +2,8 @@ "$schema": "https://turbo.build/schema.json", "tasks": { "build": { - "dependsOn": ["^build"] + "dependsOn": ["^build"], + "cache": false }, "format": { "dependsOn": ["^format"]