Skip to content

Commit

Permalink
feat: brings back --make-paths-enum option to generate ApiPaths enum (#…
Browse files Browse the repository at this point in the history
…2052)

* feat: brings back --make-paths-enum option to generate ApiPaths enum

* chore: adds --make-paths-enum flag to cli docs

* chore: adds minor changeset for

* tests: adds tests for --make-paths-enum option and paths-enum.ts transformer
  • Loading branch information
laurenz-glueck authored Dec 29, 2024
1 parent d4689b1 commit d2de5c7
Show file tree
Hide file tree
Showing 10 changed files with 276 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/healthy-rabbits-flow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"openapi-typescript": minor
---

brings back --make-paths-enum option to generate ApiPaths enum
22 changes: 21 additions & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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? |
Expand All @@ -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

Expand Down Expand Up @@ -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}",
}
```

:::
3 changes: 3 additions & 0 deletions packages/openapi-typescript/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -82,6 +83,7 @@ const flags = parser(args, {
"pathParamsAsTypes",
"rootTypes",
"rootTypesNoSchemaPrefix",
"makePathsEnum",
],
string: ["output", "redocly"],
alias: {
Expand Down Expand Up @@ -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,
}),
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
},
Expand Down
5 changes: 5 additions & 0 deletions packages/openapi-typescript/src/transform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OpenAPI3, "paths" | "webhooks" | "components" | "$defs">;

Expand Down Expand Up @@ -93,5 +94,9 @@ export default function transformSchema(schema: OpenAPI3, ctx: GlobalContext) {
);
}

if (ctx.makePathsEnum && schema.paths) {
type.push(makeApiPathsEnum(schema.paths));
}

return type;
}
47 changes: 47 additions & 0 deletions packages/openapi-typescript/src/transform/paths-enum.ts
Original file line number Diff line number Diff line change
@@ -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,
});
}
3 changes: 3 additions & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -700,6 +702,7 @@ export interface GlobalContext {
/** retrieve a node by $ref */
resolve<T>($ref: string): T | undefined;
inject?: string;
makePathsEnum: boolean;
}

export type $defs = Record<string, SchemaObject>;
Expand Down
65 changes: 65 additions & 0 deletions packages/openapi-typescript/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,71 @@ export type operations = Record<string, never>;`,
},
},
],
[
"$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<string, never>;
export interface components {
schemas: {
Item: {
id: string;
name: string;
};
};
responses: never;
parameters: never;
requestBodies: never;
headers: never;
pathItems: never;
}
export type $defs = Record<string, never>;
export type operations = Record<string, never>;
export enum ApiPaths {
GetGetitem = "/get-item"
}`,
options: {
makePathsEnum: true,
},
},
],
];

for (const [testName, { given, want, options, ci }] of tests) {
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/test/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export const DEFAULT_CTX: GlobalContext = {
},
silent: true,
transform: undefined,
makePathsEnum: false,
};

/** Generic test case */
Expand Down
125 changes: 125 additions & 0 deletions packages/openapi-typescript/test/transform/paths-enum.test.ts
Original file line number Diff line number Diff line change
@@ -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<any, GlobalContext>[] = [
[
"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,
);
}
});

0 comments on commit d2de5c7

Please sign in to comment.