From 9e125a916623929e040f6f2b8f8f39704568fe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurenz=20Glu=CC=88ck?= Date: Sat, 14 Dec 2024 17:02:15 +0100 Subject: [PATCH 1/4] feat: brings back --make-paths-enum option to generate ApiPaths enum --- packages/openapi-typescript/bin/cli.js | 3 ++ packages/openapi-typescript/src/index.ts | 1 + .../openapi-typescript/src/transform/index.ts | 5 ++ .../src/transform/paths-enum.ts | 47 +++++++++++++++++++ packages/openapi-typescript/src/types.ts | 3 ++ .../openapi-typescript/test/test-helpers.ts | 1 + 6 files changed, 60 insertions(+) create mode 100644 packages/openapi-typescript/src/transform/paths-enum.ts diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index 6cfdec986..9f8377fd5 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -33,6 +33,7 @@ Options --root-types (optional) Export schemas types at root level --root-types-no-schema-prefix (optional) Do not add "Schema" prefix to types at the root level (should only be used with --root-types) + --make-paths-enum Generate ApiPaths enum for all paths `; const OUTPUT_FILE = "FILE"; @@ -82,6 +83,7 @@ const flags = parser(args, { "pathParamsAsTypes", "rootTypes", "rootTypesNoSchemaPrefix", + "makePathsEnum", ], string: ["output", "redocly"], alias: { @@ -143,6 +145,7 @@ async function generateSchema(schema, { redocly, silent = false }) { pathParamsAsTypes: flags.pathParamsAsTypes, rootTypes: flags.rootTypes, rootTypesNoSchemaPrefix: flags.rootTypesNoSchemaPrefix, + makePathsEnum: flags.makePathsEnum, redocly, silent, }), diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index d7cbb8e27..f1b739496 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -89,6 +89,7 @@ export default async function openapiTS( silent: options.silent ?? false, inject: options.inject ?? undefined, transform: typeof options.transform === "function" ? options.transform : undefined, + makePathsEnum: options.makePathsEnum ?? false, resolve($ref) { return resolveRef(schema, $ref, { silent: options.silent ?? false }); }, diff --git a/packages/openapi-typescript/src/transform/index.ts b/packages/openapi-typescript/src/transform/index.ts index 9ad0b9ae3..52d7e5801 100644 --- a/packages/openapi-typescript/src/transform/index.ts +++ b/packages/openapi-typescript/src/transform/index.ts @@ -7,6 +7,7 @@ import transformComponentsObject from "./components-object.js"; import transformPathsObject from "./paths-object.js"; import transformSchemaObject from "./schema-object.js"; import transformWebhooksObject from "./webhooks-object.js"; +import makeApiPathsEnum from "./paths-enum.js"; type SchemaTransforms = keyof Pick; @@ -93,5 +94,9 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) { ); } + if (ctx.makePathsEnum && schema.paths) { + type.push(makeApiPathsEnum(schema.paths)); + } + return type; } diff --git a/packages/openapi-typescript/src/transform/paths-enum.ts b/packages/openapi-typescript/src/transform/paths-enum.ts new file mode 100644 index 000000000..78d33b313 --- /dev/null +++ b/packages/openapi-typescript/src/transform/paths-enum.ts @@ -0,0 +1,47 @@ +import type ts from "typescript"; +import { tsEnum } from "../lib/ts.js"; +import { getEntries } from "../lib/utils.js"; +import type { PathsObject } from "../types.js"; + +export default function makeApiPathsEnum(pathsObject: PathsObject): ts.EnumDeclaration { + const enumKeys = []; + const enumMetaData = []; + + for (const [url, pathItemObject] of getEntries(pathsObject)) { + for (const [method, operation] of Object.entries(pathItemObject)) { + if (!["get", "put", "post", "delete", "options", "head", "patch", "trace"].includes(method)) { + continue; + } + + // Generate a name from the operation ID + let pathName: string; + if (operation.operationId) { + pathName = operation.operationId; + } else { + // If the operation ID is not present, construct a name from the method and path + pathName = (method + url) + .split("/") + .map((part) => { + const capitalised = part.charAt(0).toUpperCase() + part.slice(1); + + // Remove any characters not allowed as enum keys, and attempt to remove + // named parameters. + return capitalised.replace(/{.*}|:.*|[^a-zA-Z\d_]+/, ""); + }) + .join(""); + } + + // Replace {parameters} with :parameters + const adaptedUrl = url.replace(/{(\w+)}/g, ":$1"); + + enumKeys.push(adaptedUrl); + enumMetaData.push({ + name: pathName, + }); + } + } + + return tsEnum("ApiPaths", enumKeys, enumMetaData, { + export: true, + }); +} diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 1d33dd6bb..59dabcbe9 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -668,6 +668,8 @@ export interface OpenAPITSOptions { redocly?: RedoclyConfig; /** Inject arbitrary TypeScript types into the start of the file */ inject?: string; + /** Generate ApiPaths enum */ + makePathsEnum?: boolean; } /** Context passed to all submodules */ @@ -700,6 +702,7 @@ export interface GlobalContext { /** retrieve a node by $ref */ resolve($ref: string): T | undefined; inject?: string; + makePathsEnum: boolean; } export type $defs = Record; diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 8254f3bc5..8354dbac8 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -31,6 +31,7 @@ export const DEFAULT_CTX: GlobalContext = { }, silent: true, transform: undefined, + makePathsEnum: false, }; /** Generic test case */ From aeb2f5b23524d8b8249dcc3e33c0002c57aee7c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurenz=20Glu=CC=88ck?= Date: Sat, 14 Dec 2024 17:30:50 +0100 Subject: [PATCH 2/4] chore: adds --make-paths-enum flag to cli docs --- docs/cli.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index ecef563b0..8158d7893 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -100,7 +100,7 @@ Refer to the [Redocly docs](https://redocly.com/docs/cli/configuration/#resolve- The following flags are supported in the CLI: | Flag | Alias | Default | Description | -| :--------------------------------- | :---- | :------: | :------------------------------------------------------------------------------------------------------------------ | +|:-----------------------------------| :---- | :------: |:--------------------------------------------------------------------------------------------------------------------| | `--help` | | | Display inline help message and exit | | `--version` | | | Display this library’s version and exit | | `--output [location]` | `-o` | (stdout) | Where should the output file be saved? | @@ -121,6 +121,7 @@ The following flags are supported in the CLI: | `--path-params-as-types` | | `false` | Allow dynamic string lookups on the `paths` object | | `--root-types` | | `false` | Exports types from `components` as root level type aliases | | `--root-types-no-schema-prefix` | | `false` | Do not add "Schema" prefix to types at the root level (should only be used with --root-types) | +| `--make-paths-enum ` | | `false` | Generate ApiPaths enum for all paths | ### pathParamsAsTypes @@ -207,3 +208,22 @@ This results in more explicit typechecking of array lengths. _Note: this has a reasonable limit, so for example `maxItems: 100` would simply flatten back down to `string[];`_ _Thanks, [@kgtkr](https://github.com/kgtkr)!_ + +### makePathsEnum + +This option is useful for generating an enum for all paths in the schema. This can be useful to use the paths from the schema in your code. + +Enabling `--make-paths-enum` will add an `ApiPaths` enum like this to the generated types: + +::: code-group + +```ts [my-openapi-3-schema.d.ts] +export enum ApiPaths { + "/user/{user_id}" = "/user/{user_id}", + "/user" = "/user", + "/user/{user_id}/pets" = "/user/{user_id}/pets", + "/user/{user_id}/pets/{pet_id}" = "/user/{user_id}/pets/{pet_id}", +} +``` + +::: From 253c23bf2ca41d027ec0530881e747a4e558f893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurenz=20Glu=CC=88ck?= Date: Fri, 27 Dec 2024 17:07:07 +0100 Subject: [PATCH 3/4] chore: adds minor changeset for --- .changeset/healthy-rabbits-flow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/healthy-rabbits-flow.md diff --git a/.changeset/healthy-rabbits-flow.md b/.changeset/healthy-rabbits-flow.md new file mode 100644 index 000000000..6da52288a --- /dev/null +++ b/.changeset/healthy-rabbits-flow.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +brings back --make-paths-enum option to generate ApiPaths enum From 4affc3c216c781f0f45ccc57c502521296e9ee0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurenz=20Glu=CC=88ck?= Date: Fri, 27 Dec 2024 17:37:45 +0100 Subject: [PATCH 4/4] tests: adds tests for --make-paths-enum option and paths-enum.ts transformer --- .../openapi-typescript/test/index.test.ts | 65 +++++++++ .../test/transform/paths-enum.test.ts | 125 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 packages/openapi-typescript/test/transform/paths-enum.test.ts diff --git a/packages/openapi-typescript/test/index.test.ts b/packages/openapi-typescript/test/index.test.ts index 46b01c8b8..3b81ead07 100644 --- a/packages/openapi-typescript/test/index.test.ts +++ b/packages/openapi-typescript/test/index.test.ts @@ -694,6 +694,71 @@ export type operations = Record;`, }, }, ], + [ + "$refs > path object & paths enum", + { + given: new URL("./fixtures/path-object-refs.yaml", import.meta.url), + want: `export interface paths { + "/get-item": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Item"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Item: { + id: string; + name: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; +export enum ApiPaths { + GetGetitem = "/get-item" +}`, + options: { + makePathsEnum: true, + }, + }, + ], ]; for (const [testName, { given, want, options, ci }] of tests) { diff --git a/packages/openapi-typescript/test/transform/paths-enum.test.ts b/packages/openapi-typescript/test/transform/paths-enum.test.ts new file mode 100644 index 000000000..37ee91e23 --- /dev/null +++ b/packages/openapi-typescript/test/transform/paths-enum.test.ts @@ -0,0 +1,125 @@ +import { fileURLToPath } from "node:url"; +import { astToString } from "../../src/lib/ts.js"; +import makeApiPathsEnum from "../../src/transform/paths-enum.js"; +import type { GlobalContext } from "../../src/types.js"; +import type { TestCase } from "../test-helpers.js"; + +describe("transformPathsObjectToEnum", () => { + const tests: TestCase[] = [ + [ + "basic", + { + given: { + "/api/v1/user": { + get: {}, + }, + }, + want: `export enum ApiPaths { + GetApiV1User = "/api/v1/user" +}`, + }, + ], + [ + "basic with path parameter", + { + given: { + "/api/v1/user/{user_id}": { + parameters: [ + { + name: "page", + in: "query", + schema: { type: "number" }, + description: "Page number.", + }, + ], + get: { + parameters: [{ name: "user_id", in: "path", description: "User ID." }], + }, + }, + }, + want: `export enum ApiPaths { + GetApiV1User = "/api/v1/user/:user_id" +}`, + }, + ], + [ + "with operationId", + { + given: { + "/api/v1/user/{user_id}": { + parameters: [ + { + name: "page", + in: "query", + schema: { type: "number" }, + description: "Page number.", + }, + ], + get: { + operationId: "GetUserById", + parameters: [{ name: "user_id", in: "path", description: "User ID." }], + }, + }, + }, + want: `export enum ApiPaths { + GetUserById = "/api/v1/user/:user_id" +}`, + }, + ], + [ + "with and without operationId", + { + given: { + "/api/v1/user/{user_id}": { + parameters: [ + { + name: "page", + in: "query", + schema: { type: "number" }, + description: "Page number.", + }, + ], + get: { + operationId: "GetUserById", + parameters: [{ name: "user_id", in: "path", description: "User ID." }], + }, + post: { + parameters: [{ name: "user_id", in: "path", description: "User ID." }], + }, + }, + }, + want: `export enum ApiPaths { + GetUserById = "/api/v1/user/:user_id", + PostApiV1User = "/api/v1/user/:user_id" +}`, + }, + ], + [ + "invalid method", + { + given: { + "/api/v1/user": { + invalidMethod: {}, + }, + }, + want: `export enum ApiPaths { +}`, + }, + ], + ]; + + for (const [testName, { given, want, ci }] of tests) { + test.skipIf(ci?.skipIf)( + testName, + async () => { + const result = astToString(makeApiPathsEnum(given)); + if (want instanceof URL) { + expect(result).toMatchFileSnapshot(fileURLToPath(want)); + } else { + expect(result).toBe(`${want}\n`); + } + }, + ci?.timeout, + ); + } +});