diff --git a/.changeset/dirty-apes-itch.md b/.changeset/dirty-apes-itch.md new file mode 100644 index 000000000..4a2bb16c6 --- /dev/null +++ b/.changeset/dirty-apes-itch.md @@ -0,0 +1,5 @@ +--- +"openapi-typescript": minor +--- + +Add option to handle parameters with default as non-optional diff --git a/docs/cli.md b/docs/cli.md index fb41bd941..da64c86a1 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -234,3 +234,12 @@ export enum ApiPaths { 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. + +### makeParametersWithDefaultNotUndefined + +Enabling `--make-parameters-with-default-not-undefined` will result in generated types where operation parameters that +have default values defined are non-optional. + +This allows generating types for certain server implementation use cases where the default value is injected +automatically by the framework for the handling function to consume and the signature on the handler should +indicate that the value can not be undefined. diff --git a/packages/openapi-typescript/bin/cli.js b/packages/openapi-typescript/bin/cli.js index c3a589dc5..d2b78953b 100755 --- a/packages/openapi-typescript/bin/cli.js +++ b/packages/openapi-typescript/bin/cli.js @@ -34,6 +34,8 @@ Options --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 + --make-parameters-with-default-not-undefined + Generate non-optional parameter types if the parameter has a default `; const OUTPUT_FILE = "FILE"; @@ -86,6 +88,7 @@ const flags = parser(args, { "rootTypesNoSchemaPrefix", "makePathsEnum", "generatePathParams", + "makeParametersWithDefaultNotUndefined", ], string: ["output", "redocly"], alias: { @@ -149,6 +152,7 @@ async function generateSchema(schema, { redocly, silent = false }) { rootTypesNoSchemaPrefix: flags.rootTypesNoSchemaPrefix, makePathsEnum: flags.makePathsEnum, generatePathParams: flags.generatePathParams, + makeParametersWithDefaultNotUndefined: flags.makeParametersWithDefaultNotUndefined, redocly, silent, }), diff --git a/packages/openapi-typescript/examples/simple-example.ts b/packages/openapi-typescript/examples/simple-example.ts index 88f9c2041..e98bfea5f 100644 --- a/packages/openapi-typescript/examples/simple-example.ts +++ b/packages/openapi-typescript/examples/simple-example.ts @@ -112,7 +112,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Checks if unevaluetedProperties work */ + /** Checks if unevaluatedProperties work */ get: operations["getUnevaluatedProperties"]; put?: never; post?: never; diff --git a/packages/openapi-typescript/examples/simple-example.yaml b/packages/openapi-typescript/examples/simple-example.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/examples/simple-example.yaml +++ b/packages/openapi-typescript/examples/simple-example.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index dabd4e81b..36f4efba2 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -95,6 +95,7 @@ export default async function openapiTS( resolve($ref) { return resolveRef(schema, $ref, { silent: options.silent ?? false }); }, + makeParametersWithDefaultNotUndefined: options.makeParametersWithDefaultNotUndefined ?? false, }; const transformT = performance.now(); diff --git a/packages/openapi-typescript/src/transform/parameters-array.ts b/packages/openapi-typescript/src/transform/parameters-array.ts index 1ecdff4d7..6cd3798fd 100644 --- a/packages/openapi-typescript/src/transform/parameters-array.ts +++ b/packages/openapi-typescript/src/transform/parameters-array.ts @@ -1,7 +1,7 @@ import ts from "typescript"; import { addJSDocComment, NEVER, oapiRef, QUESTION_TOKEN, tsModifiers, tsPropertyIndex } from "../lib/ts.js"; import { createRef } from "../lib/utils.js"; -import type { ParameterObject, ReferenceObject, TransformNodeOptions } from "../types.js"; +import type { ParameterObject, ReferenceObject, SchemaObject, TransformNodeOptions } from "../types.js"; import transformParameterObject from "./parameter-object.js"; // Regex to match path parameters in URL @@ -86,8 +86,15 @@ export function transformParametersArray( if (resolved?.in !== paramIn) { continue; } + const resolvedSchema = + resolved.schema && "$ref" in resolved.schema + ? options.ctx.resolve(resolved.schema.$ref as string) + : resolved.schema; let optional: ts.QuestionToken | undefined = undefined; - if (paramIn !== "path" && !(resolved as ParameterObject).required) { + const isNonOptional = + (resolved as ParameterObject).required || + (options.ctx.makeParametersWithDefaultNotUndefined && resolvedSchema?.default !== undefined); + if (paramIn !== "path" && !isNonOptional) { optional = QUESTION_TOKEN; } const subType = diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 0020bc03e..a9275a330 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -679,6 +679,10 @@ export interface OpenAPITSOptions { makePathsEnum?: boolean; /** Generate path params based on path even if they are not defiend in the open api schema */ generatePathParams?: boolean; + /** Generate non-optional parameter types if the parameter has a default. This is useful + * for server implementations that automatically set the default from the spec for the user. + */ + makeParametersWithDefaultNotUndefined?: boolean; } /** Context passed to all submodules */ @@ -714,6 +718,7 @@ export interface GlobalContext { inject?: string; makePathsEnum: boolean; generatePathParams: boolean; + makeParametersWithDefaultNotUndefined: boolean; } export type $defs = Record; diff --git a/packages/openapi-typescript/test/fixtures/parameters-test-default.yaml b/packages/openapi-typescript/test/fixtures/parameters-test-default.yaml new file mode 100644 index 000000000..938818e77 --- /dev/null +++ b/packages/openapi-typescript/test/fixtures/parameters-test-default.yaml @@ -0,0 +1,19 @@ +openapi: "3.0" +info: + title: test + version: "1.0" +paths: + /endpoint: + get: + description: OK + parameters: + - in: query + name: optional_nodefault + schema: + type: string + required: false + - in: query + name: optional_default + schema: + type: string + required: false diff --git a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/a.yaml b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/a.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/a.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/a.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/b.yaml b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/b.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/b.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/b.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/c.yaml b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/c.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/c.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly-flag/openapi/c.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/fixtures/redocly/openapi/a.yaml b/packages/openapi-typescript/test/fixtures/redocly/openapi/a.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly/openapi/a.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly/openapi/a.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/fixtures/redocly/openapi/b.yaml b/packages/openapi-typescript/test/fixtures/redocly/openapi/b.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly/openapi/b.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly/openapi/b.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/fixtures/redocly/openapi/c.yaml b/packages/openapi-typescript/test/fixtures/redocly/openapi/c.yaml index f6323a406..b5d309a88 100644 --- a/packages/openapi-typescript/test/fixtures/redocly/openapi/c.yaml +++ b/packages/openapi-typescript/test/fixtures/redocly/openapi/c.yaml @@ -109,7 +109,7 @@ paths: /unevaluated-properties: get: operationId: getUnevaluatedProperties - summary: Checks if unevaluetedProperties work + summary: Checks if unevaluatedProperties work responses: 200: description: 'OK' diff --git a/packages/openapi-typescript/test/node-api.test.ts b/packages/openapi-typescript/test/node-api.test.ts index 51390097e..a7ea7cf75 100644 --- a/packages/openapi-typescript/test/node-api.test.ts +++ b/packages/openapi-typescript/test/node-api.test.ts @@ -58,7 +58,7 @@ export type operations = Record;`, [ "input > string > URL", { - given: "https://raw.githubusercontent.com/Redocly/redocly-cli/main/__tests__/lint/oas3.1/openapi.yaml", + given: "https://raw.githubusercontent.com/Redocly/redocly-cli/main/tests/e2e/lint/oas3.1/openapi.yaml", want: new URL("simple-example.ts", EXAMPLES_DIR), // options: DEFAULT_OPTIONS, }, @@ -66,7 +66,7 @@ export type operations = Record;`, [ "input > URL > remote", { - given: new URL("https://raw.githubusercontent.com/Redocly/redocly-cli/main/__tests__/lint/oas3.1/openapi.yaml"), + given: new URL("https://raw.githubusercontent.com/Redocly/redocly-cli/main/tests/e2e/lint/oas3.1/openapi.yaml"), want: new URL("simple-example.ts", EXAMPLES_DIR), // options: DEFAULT_OPTIONS, }, diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index 13d46568a..f0c2a13d0 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -34,6 +34,7 @@ export const DEFAULT_CTX: GlobalContext = { transformProperty: undefined, makePathsEnum: false, generatePathParams: false, + makeParametersWithDefaultNotUndefined: false, }; /** Generic test case */ diff --git a/packages/openapi-typescript/test/transform/parameters-with-default.test.ts b/packages/openapi-typescript/test/transform/parameters-with-default.test.ts new file mode 100644 index 000000000..2550f9874 --- /dev/null +++ b/packages/openapi-typescript/test/transform/parameters-with-default.test.ts @@ -0,0 +1,152 @@ +import { fileURLToPath } from "node:url"; +import { astToString } from "../../src/lib/ts.js"; +import transformPathsObject from "../../src/transform/paths-object.js"; +import type { GlobalContext } from "../../src/types.js"; +import { DEFAULT_CTX, type TestCase } from "../test-helpers.js"; + +const DEFAULT_OPTIONS = DEFAULT_CTX; + +describe("parametersWithDefaults", () => { + const tests: TestCase[] = [ + [ + "options > default", + { + given: { + "/api/{version}/user/{user_id}": { + parameters: [ + { in: "query", name: "no_default", required: false, schema: { type: "number" } }, + { in: "query", name: "with_default", required: false, schema: { type: "number", default: 1337 } }, + { + in: "query", + name: "ref_without_default", + required: false, + schema: { $ref: "#/components/schemas/NoDefault" }, + }, + { + in: "query", + name: "ref_with_default", + required: false, + schema: { $ref: "#/components/schemas/WithDefault" }, + }, + ], + }, + }, + want: `{ + "/api/{version}/user/{user_id}": { + parameters: { + query?: { + no_default?: number; + with_default?: number; + ref_without_default?: components["schemas"]["NoDefault"]; + ref_with_default?: components["schemas"]["WithDefault"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}`, + options: { + ...DEFAULT_OPTIONS, + }, + }, + ], + [ + "options > makeParametersWithDefaultNotUndefined: true", + { + given: { + "/api/{version}/user/{user_id}": { + parameters: [ + { in: "query", name: "no_default", required: false, schema: { type: "number" } }, + { in: "query", name: "with_default", required: false, schema: { type: "number", default: 1337 } }, + { + in: "query", + name: "ref_without_default", + required: false, + schema: { $ref: "#/components/schemas/NoDefault" }, + }, + { + in: "query", + name: "ref_with_default", + required: false, + schema: { $ref: "#/components/schemas/WithDefault" }, + }, + ], + }, + }, + want: `{ + "/api/{version}/user/{user_id}": { + parameters: { + query: { + no_default?: number; + with_default: number; + ref_without_default?: components["schemas"]["NoDefault"]; + ref_with_default: components["schemas"]["WithDefault"]; + }; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}`, + options: { + ...DEFAULT_OPTIONS, + makeParametersWithDefaultNotUndefined: true, + }, + }, + ], + ]; + + for (const [testName, { given, want, options = DEFAULT_OPTIONS, ci }] of tests) { + test.skipIf(ci?.skipIf)( + testName, + async () => { + const result = astToString( + transformPathsObject(given, { + ...options, + resolve($ref) { + switch ($ref) { + case "#/components/schemas/NoDefault": { + return { + type: "number", + }; + } + case "#/components/schemas/WithDefault": { + return { + type: "number", + default: 1338, + }; + } + default: { + return undefined as any; + } + } + }, + }), + ); + if (want instanceof URL) { + await expect(result).toMatchFileSnapshot(fileURLToPath(want)); + } else { + expect(result).toBe(`${want}\n`); + } + }, + ci?.timeout, + ); + } +});