From 9d221338520c1c3d4af12078a7925fd81ca87524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20P=C3=B6ntinen?= Date: Fri, 17 Jan 2025 12:12:20 +0100 Subject: [PATCH] feat(openapi-typescript): generate path params flag generate path params flag --- .changeset/purple-walls-repeat.md | 5 + docs/cli.md | 9 +- packages/openapi-typescript/bin/cli.js | 2 + packages/openapi-typescript/src/index.ts | 1 + .../src/transform/parameters-array.ts | 54 ++++++- packages/openapi-typescript/src/types.ts | 3 + .../test/fixtures/generate-params-test.yaml | 28 ++++ .../openapi-typescript/test/index.test.ts | 146 ++++++++++++++++++ .../openapi-typescript/test/test-helpers.ts | 1 + 9 files changed, 247 insertions(+), 2 deletions(-) create mode 100644 .changeset/purple-walls-repeat.md create mode 100644 packages/openapi-typescript/test/fixtures/generate-params-test.yaml diff --git a/.changeset/purple-walls-repeat.md b/.changeset/purple-walls-repeat.md new file mode 100644 index 000000000..72489f9ef --- /dev/null +++ b/.changeset/purple-walls-repeat.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Support generating path params for flaky schemas using --generate-path-params option diff --git a/docs/cli.md b/docs/cli.md index 8158d7893..fb41bd941 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -121,7 +121,8 @@ 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 | +| `--make-paths-enum` | | `false` | Generate ApiPaths enum for all paths | +| `--generate-path-params` | | `false` | Generate path parameters for all paths where they are undefined by schema | ### pathParamsAsTypes @@ -227,3 +228,9 @@ export enum ApiPaths { ``` ::: + +### generatePathParams + +This option is useful for generating path params optimistically when the schema has flaky path parameter definitions. +Checks the path for opening and closing brackets and extracts them as path parameters. +Does not override already defined by schema path parameters. diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index 9f8377fd5..d40bd6606 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -84,6 +84,7 @@ const flags = parser(args, { "rootTypes", "rootTypesNoSchemaPrefix", "makePathsEnum", + "generatePathParams", ], string: ["output", "redocly"], alias: { @@ -146,6 +147,7 @@ async function generateSchema(schema, { redocly, silent = false }) { rootTypes: flags.rootTypes, rootTypesNoSchemaPrefix: flags.rootTypesNoSchemaPrefix, makePathsEnum: flags.makePathsEnum, + generatePathParams: flags.generatePathParams, redocly, silent, }), diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index f1b739496..8c36fd0ad 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -90,6 +90,7 @@ export default async function openapiTS( inject: options.inject ?? undefined, transform: typeof options.transform === "function" ? options.transform : undefined, makePathsEnum: options.makePathsEnum ?? false, + generatePathParams: options.generatePathParams ?? false, resolve($ref) { return resolveRef(schema, $ref, { silent: options.silent ?? false }); }, diff --git a/packages/openapi-typescript/src/transform/parameters-array.ts b/packages/openapi-typescript/src/transform/parameters-array.ts index 6967b67fb..be995b75a 100644 --- a/packages/openapi-typescript/src/transform/parameters-array.ts +++ b/packages/openapi-typescript/src/transform/parameters-array.ts @@ -4,6 +4,36 @@ import { createRef } from "../lib/utils.js"; import type { ParameterObject, ReferenceObject, TransformNodeOptions } from "../types.js"; import transformParameterObject from "./parameter-object.js"; +// Regex to match path parameters in URL +const PATH_PARAM_RE = /\{([^}]+)\}/g; + +/** + * Create a synthetic path parameter object from a parameter name + */ +function createPathParameter(paramName: string): ParameterObject { + return { + name: paramName, + in: "path", + required: true, + schema: { type: "string" }, + }; +} + +/** + * Extract path parameters from a URL + */ +function extractPathParamsFromUrl(path: string): ParameterObject[] { + const params: ParameterObject[] = []; + const matches = path.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + const paramName = match.slice(1, -1); + params.push(createPathParameter(paramName)); + } + } + return params; +} + /** * Synthetic type. Array of (ParameterObject | ReferenceObject)s found in OperationObject and PathItemObject. */ @@ -13,14 +43,36 @@ export function transformParametersArray( ): ts.TypeElement[] { const type: ts.TypeElement[] = []; + // Create a working copy of parameters array + const workingParameters = [...parametersArray]; + + // Generate path parameters if enabled + if (options.ctx.generatePathParams && options.path) { + const pathString = Array.isArray(options.path) ? options.path[0] : options.path; + if (typeof pathString === "string") { + const pathParams = extractPathParamsFromUrl(pathString); + // Only add path parameters that aren't already defined + for (const param of pathParams) { + const exists = workingParameters.some((p) => { + const resolved = "$ref" in p ? options.ctx.resolve(p.$ref) : p; + return resolved?.in === "path" && resolved?.name === param.name; + }); + if (!exists) { + workingParameters.push(param); + } + } + } + } + // parameters const paramType: ts.TypeElement[] = []; for (const paramIn of ["query", "header", "path", "cookie"] as ParameterObject["in"][]) { const paramLocType: ts.TypeElement[] = []; - let operationParameters = parametersArray.map((param) => ({ + let operationParameters = workingParameters.map((param) => ({ original: param, resolved: "$ref" in param ? options.ctx.resolve(param.$ref) : param, })); + // this is the only array type in the spec, so we have to one-off sort here if (options.ctx.alphabetize) { operationParameters.sort((a, b) => (a.resolved?.name ?? "").localeCompare(b.resolved?.name ?? "")); diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index bd11ecca0..75d8f8c07 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -670,6 +670,8 @@ export interface OpenAPITSOptions { inject?: string; /** Generate ApiPaths enum */ makePathsEnum?: boolean; + /** Generate path params based on path even if they are not defiend in the open api schema */ + generatePathParams?: boolean; } /** Context passed to all submodules */ @@ -703,6 +705,7 @@ export interface GlobalContext { resolve($ref: string): T | undefined; inject?: string; makePathsEnum: boolean; + generatePathParams: boolean; } export type $defs = Record; diff --git a/packages/openapi-typescript/test/fixtures/generate-params-test.yaml b/packages/openapi-typescript/test/fixtures/generate-params-test.yaml new file mode 100644 index 000000000..bd7c2449c --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/generate-params-test.yaml @@ -0,0 +1,28 @@ +openapi: "3.0" +info: + title: Test + version: "1.0" +paths: + /{id}/get-item-undefined-path-param: + description: Remote Ref + $ref: "_path-object-refs-paths.yaml#/GetItemOperation" + /{id}/get-item-undefined-nested-path-param/{secondId}: + description: Remote Ref + $ref: "_path-object-refs-paths.yaml#/GetItemOperation" + parameters: + - + name: id + in: path + required: true + schema: + type: number + /{id}/get-item-defined-path-param: + description: Remote Ref + $ref: "_path-object-refs-paths.yaml#/GetItemOperation" + parameters: + - + name: id + in: path + required: true + schema: + type: number diff --git a/packages/openapi-typescript/test/index.test.ts b/packages/openapi-typescript/test/index.test.ts index 0789d6757..8cbef350a 100644 --- a/packages/openapi-typescript/test/index.test.ts +++ b/packages/openapi-typescript/test/index.test.ts @@ -759,6 +759,152 @@ export enum ApiPaths { }, }, ], + [ + "Generates path parameters", + { + given: new URL("./fixtures/generate-params-test.yaml", import.meta.url), + want: `export interface paths { + "/{id}/get-item-undefined-path-param": { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + 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; + }; + "/{id}/get-item-undefined-nested-path-param/{secondId}": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + secondId: string; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + secondId: string; + }; + 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; + }; + "/{id}/get-item-defined-path-param": { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + 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;`, + options: { + generatePathParams: true, + }, + }, + ], [ "nullable > 3.0 syntax", { diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 8354dbac8..72e44fccc 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -32,6 +32,7 @@ export const DEFAULT_CTX: GlobalContext = { silent: true, transform: undefined, makePathsEnum: false, + generatePathParams: false, }; /** Generic test case */