From d55a70e1ea465daf6fb74f7d7c31b23f8e428327 Mon Sep 17 00:00:00 2001 From: Adam Banky Date: Thu, 11 Dec 2025 00:22:06 +0100 Subject: [PATCH 1/4] fix: nullable is not respected if type: object #533 --- package.json | 10 +-- src/schema-parser/schema-utils.ts | 28 +++++-- tests/__snapshots__/extended.test.ts.snap | 74 +++++++++-------- tests/__snapshots__/simple.test.ts.snap | 74 +++++++++-------- .../__snapshots__/basic.test.ts.snap | 2 +- .../__snapshots__/basic.test.ts.snap | 44 ++++++++++ .../basic.test.ts | 35 ++++++++ .../schema.json | 80 +++++++++++++++++++ 8 files changed, 263 insertions(+), 84 deletions(-) create mode 100644 tests/spec/nullable-parent-with-nullable-children/__snapshots__/basic.test.ts.snap create mode 100644 tests/spec/nullable-parent-with-nullable-children/basic.test.ts create mode 100644 tests/spec/nullable-parent-with-nullable-children/schema.json diff --git a/package.json b/package.json index 5c8f4a04c..f673a612f 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,21 @@ "type": "module", "exports": { ".": { - "import": "./dist/index.js", + "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, "./cli": { - "import": "./dist/cli.js", + "import": "./dist/cli.mjs", "require": "./dist/cli.cjs" }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "types": "./dist/index.d.cts", "bin": { - "sta": "./dist/cli.js", - "swagger-typescript-api": "./dist/cli.js" + "sta": "./dist/cli.mjs", + "swagger-typescript-api": "./dist/cli.mjs" }, "files": [ "dist", diff --git a/src/schema-parser/schema-utils.ts b/src/schema-parser/schema-utils.ts index bf7614e33..2f76c1d5f 100644 --- a/src/schema-parser/schema-utils.ts +++ b/src/schema-parser/schema-utils.ts @@ -85,14 +85,26 @@ export class SchemaUtils { isNullMissingInType = (schema, type) => { const { nullable, type: schemaType } = schema || {}; - return ( - (nullable || - !!lodash.get(schema, "x-nullable") || - schemaType === this.config.Ts.Keyword.Null) && - typeof type === "string" && - !type.includes(` ${this.config.Ts.Keyword.Null}`) && - !type.includes(`${this.config.Ts.Keyword.Null} `) - ); + + // Check if schema indicates nullable + const isSchemaMarkedNullable = + nullable || + !!lodash.get(schema, "x-nullable") || + schemaType === this.config.Ts.Keyword.Null; + + if (!isSchemaMarkedNullable) return false; + if (typeof type !== "string") return false; + + // Only check for root-level null in union types + // Match patterns: "... | null" or "null | ..." at the root level + // This avoids false positives from nested nullable properties like { prop: string | null } + const nullKeyword = this.config.Ts.Keyword.Null; + const hasRootLevelNull = + type.trim() === nullKeyword || + new RegExp(`\\|\\s*${nullKeyword}\\s*$`).test(type) || // Ends with | null + new RegExp(`^\\s*${nullKeyword}\\s*\\|`).test(type); // Starts with null | + + return !hasRootLevelNull; }; safeAddNullToType = (schema, type) => { diff --git a/tests/__snapshots__/extended.test.ts.snap b/tests/__snapshots__/extended.test.ts.snap index e95bc232a..c11fc422e 100644 --- a/tests/__snapshots__/extended.test.ts.snap +++ b/tests/__snapshots__/extended.test.ts.snap @@ -15814,17 +15814,19 @@ export enum CodeScanningAlertDismissedReasonEnum { /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ export type CodeScanningAlertEnvironment = string; -export type CodeScanningAlertInstances = { - /** Identifies the configuration under which the analysis was executed. For example, in GitHub Actions this includes the workflow filename and job name. */ - analysis_key?: CodeScanningAnalysisAnalysisKey; - /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ - environment?: CodeScanningAlertEnvironment; - matrix_vars?: string | null; - /** The full Git reference, formatted as \`refs/heads/\`. */ - ref?: CodeScanningAlertRef; - /** State of a code scanning alert. */ - state?: CodeScanningAlertState; -}[]; +export type CodeScanningAlertInstances = + | { + /** Identifies the configuration under which the analysis was executed. For example, in GitHub Actions this includes the workflow filename and job name. */ + analysis_key?: CodeScanningAnalysisAnalysisKey; + /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ + environment?: CodeScanningAlertEnvironment; + matrix_vars?: string | null; + /** The full Git reference, formatted as \`refs/heads/\`. */ + ref?: CodeScanningAlertRef; + /** State of a code scanning alert. */ + state?: CodeScanningAlertState; + }[] + | null; /** The full Git reference, formatted as \`refs/heads/\`. */ export type CodeScanningAlertRef = string; @@ -18696,26 +18698,28 @@ export interface GistsUpdateParams { gistId: string; } -export type GistsUpdatePayload = null & { - /** - * Description of the gist - * @example "Example Ruby script" - */ - description?: string; - /** - * Names of files to be updated - * @example {"hello.rb":{"content":"blah","filename":"goodbye.rb"}} - */ - files?: Record< - string, - (object | null) & { - /** The new content of the file */ - content?: string; - /** The new filename for the file */ - filename?: string | null; - } - >; -}; +export type GistsUpdatePayload = null & + ({ + /** + * Description of the gist + * @example "Example Ruby script" + */ + description?: string; + /** + * Names of files to be updated + * @example {"hello.rb":{"content":"blah","filename":"goodbye.rb"}} + */ + files?: Record< + string, + (object | null) & + ({ + /** The new content of the file */ + content?: string; + /** The new filename for the file */ + filename?: string | null; + } | null) + >; + } | null); /** * Git Commit @@ -21629,7 +21633,7 @@ export interface MarketplacePurchase { /** Marketplace Listing Plan */ plan?: MarketplaceListingPlan; unit_count?: number | null; - }; + } | null; marketplace_purchase: { billing_cycle?: string; free_trial_ends_on?: string | null; @@ -25078,7 +25082,7 @@ export interface PullRequest { spdx_id: string | null; /** @format uri */ url: string | null; - }; + } | null; master_branch?: string; /** @format uri */ merges_url: string; @@ -31278,7 +31282,7 @@ export type SimpleUser = { * @example "https://api.github.com/users/octocat" */ url: string; -}; +} | null; /** * What to sort results by. Can be either \`created\`, \`updated\`, \`comments\`. @@ -32356,7 +32360,7 @@ export type TeamSimple = { * @example "https://api.github.com/organizations/1/team/1" */ url: string; -}; +} | null; export type TeamsAddMemberLegacyData = any; diff --git a/tests/__snapshots__/simple.test.ts.snap b/tests/__snapshots__/simple.test.ts.snap index 710b23008..dcb8b8168 100644 --- a/tests/__snapshots__/simple.test.ts.snap +++ b/tests/__snapshots__/simple.test.ts.snap @@ -9534,17 +9534,19 @@ export type CodeScanningAlertDismissedReason = /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ export type CodeScanningAlertEnvironment = string; -export type CodeScanningAlertInstances = { - /** Identifies the configuration under which the analysis was executed. For example, in GitHub Actions this includes the workflow filename and job name. */ - analysis_key?: CodeScanningAnalysisAnalysisKey; - /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ - environment?: CodeScanningAlertEnvironment; - matrix_vars?: string | null; - /** The full Git reference, formatted as \`refs/heads/\`. */ - ref?: CodeScanningAlertRef; - /** State of a code scanning alert. */ - state?: CodeScanningAlertState; -}[]; +export type CodeScanningAlertInstances = + | { + /** Identifies the configuration under which the analysis was executed. For example, in GitHub Actions this includes the workflow filename and job name. */ + analysis_key?: CodeScanningAnalysisAnalysisKey; + /** Identifies the variable values associated with the environment in which the analysis that generated this alert instance was performed, such as the language that was analyzed. */ + environment?: CodeScanningAlertEnvironment; + matrix_vars?: string | null; + /** The full Git reference, formatted as \`refs/heads/\`. */ + ref?: CodeScanningAlertRef; + /** State of a code scanning alert. */ + state?: CodeScanningAlertState; + }[] + | null; /** The full Git reference, formatted as \`refs/heads/\`. */ export type CodeScanningAlertRef = string; @@ -12595,7 +12597,7 @@ export interface MarketplacePurchase { /** Marketplace Listing Plan */ plan?: MarketplaceListingPlan; unit_count?: number | null; - }; + } | null; marketplace_purchase: { billing_cycle?: string; free_trial_ends_on?: string | null; @@ -14084,7 +14086,7 @@ export interface PullRequest { spdx_id: string | null; /** @format uri */ url: string | null; - }; + } | null; master_branch?: string; /** @format uri */ merges_url: string; @@ -16031,7 +16033,7 @@ export type SimpleUser = { * @example "https://api.github.com/users/octocat" */ url: string; -}; +} | null; /** * Stargazer @@ -16739,7 +16741,7 @@ export type TeamSimple = { * @example "https://api.github.com/organizations/1/team/1" */ url: string; -}; +} | null; /** * Thread @@ -19479,26 +19481,28 @@ export class Api< */ gistsUpdate: ( gistId: string, - data: null & { - /** - * Description of the gist - * @example "Example Ruby script" - */ - description?: string; - /** - * Names of files to be updated - * @example {"hello.rb":{"content":"blah","filename":"goodbye.rb"}} - */ - files?: Record< - string, - (object | null) & { - /** The new content of the file */ - content?: string; - /** The new filename for the file */ - filename?: string | null; - } - >; - }, + data: null & + ({ + /** + * Description of the gist + * @example "Example Ruby script" + */ + description?: string; + /** + * Names of files to be updated + * @example {"hello.rb":{"content":"blah","filename":"goodbye.rb"}} + */ + files?: Record< + string, + (object | null) & + ({ + /** The new content of the file */ + content?: string; + /** The new filename for the file */ + filename?: string | null; + } | null) + >; + } | null), params: RequestParams = {}, ) => this.request({ diff --git a/tests/spec/additional-properties-2.0/__snapshots__/basic.test.ts.snap b/tests/spec/additional-properties-2.0/__snapshots__/basic.test.ts.snap index 1c17d1cea..cf6930535 100644 --- a/tests/spec/additional-properties-2.0/__snapshots__/basic.test.ts.snap +++ b/tests/spec/additional-properties-2.0/__snapshots__/basic.test.ts.snap @@ -33,7 +33,7 @@ export type MyObject4 = Record< { content?: string; filename?: string | null; - } + } | null >; " `; diff --git a/tests/spec/nullable-parent-with-nullable-children/__snapshots__/basic.test.ts.snap b/tests/spec/nullable-parent-with-nullable-children/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..8813db8af --- /dev/null +++ b/tests/spec/nullable-parent-with-nullable-children/__snapshots__/basic.test.ts.snap @@ -0,0 +1,44 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`nullable-parent-with-nullable-children > nullable parent object with nullable child properties 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** A nullable user object with nullable email property */ +export type UserWithNullableEmail = { + id: string; + email?: string | null; + name?: string | null; +} | null; + +/** A nullable profile with all nullable properties */ +export type Profile = { + bio?: string | null; + avatar?: string | null; + age?: number | null; +} | null; + +export interface NestedNullableObject { + outerField: string; + innerObject?: { + innerField?: string | null; + } | null; +} + +export interface Container { + /** A nullable user object with nullable email property */ + user?: UserWithNullableEmail; + /** A nullable profile with all nullable properties */ + profile?: Profile; +} +" +`; diff --git a/tests/spec/nullable-parent-with-nullable-children/basic.test.ts b/tests/spec/nullable-parent-with-nullable-children/basic.test.ts new file mode 100644 index 000000000..8a38a795c --- /dev/null +++ b/tests/spec/nullable-parent-with-nullable-children/basic.test.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("nullable-parent-with-nullable-children", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp( + path.join(os.tmpdir(), "swagger-typescript-api"), + ); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("nullable parent object with nullable child properties", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: false, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/nullable-parent-with-nullable-children/schema.json b/tests/spec/nullable-parent-with-nullable-children/schema.json new file mode 100644 index 000000000..90d245b8d --- /dev/null +++ b/tests/spec/nullable-parent-with-nullable-children/schema.json @@ -0,0 +1,80 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Nullable Parent with Nullable Children Test", + "version": "1.0.0" + }, + "paths": {}, + "components": { + "schemas": { + "UserWithNullableEmail": { + "type": "object", + "nullable": true, + "description": "A nullable user object with nullable email property", + "properties": { + "id": { + "type": "string" + }, + "email": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + } + }, + "required": ["id"] + }, + "Profile": { + "type": "object", + "nullable": true, + "description": "A nullable profile with all nullable properties", + "properties": { + "bio": { + "type": "string", + "nullable": true + }, + "avatar": { + "type": "string", + "nullable": true + }, + "age": { + "type": "integer", + "nullable": true + } + } + }, + "NestedNullableObject": { + "type": "object", + "properties": { + "outerField": { + "type": "string" + }, + "innerObject": { + "type": "object", + "nullable": true, + "properties": { + "innerField": { + "type": "string", + "nullable": true + } + } + } + }, + "required": ["outerField"] + }, + "Container": { + "type": "object", + "properties": { + "user": { + "$ref": "#/components/schemas/UserWithNullableEmail" + }, + "profile": { + "$ref": "#/components/schemas/Profile" + } + } + } + } + } +} From 6c08c87858859bdae788ca6e3777063b9c3055df Mon Sep 17 00:00:00 2001 From: Adam Banky Date: Thu, 11 Dec 2025 00:36:33 +0100 Subject: [PATCH 2/4] fix: yarn immutable check issue --- package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index f673a612f..5c8f4a04c 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,21 @@ "type": "module", "exports": { ".": { - "import": "./dist/index.mjs", + "import": "./dist/index.js", "require": "./dist/index.cjs" }, "./cli": { - "import": "./dist/cli.mjs", + "import": "./dist/cli.js", "require": "./dist/cli.cjs" }, "./package.json": "./package.json" }, "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "module": "./dist/index.js", "types": "./dist/index.d.cts", "bin": { - "sta": "./dist/cli.mjs", - "swagger-typescript-api": "./dist/cli.mjs" + "sta": "./dist/cli.js", + "swagger-typescript-api": "./dist/cli.js" }, "files": [ "dist", From 4bddc9c589ac23668b9665271b8e9bb7069f03e2 Mon Sep 17 00:00:00 2001 From: Adam Banky Date: Thu, 11 Dec 2025 00:38:52 +0100 Subject: [PATCH 3/4] fix: lint issue --- .../spec/nullable-parent-with-nullable-children/basic.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/spec/nullable-parent-with-nullable-children/basic.test.ts b/tests/spec/nullable-parent-with-nullable-children/basic.test.ts index 8a38a795c..3a73d711e 100644 --- a/tests/spec/nullable-parent-with-nullable-children/basic.test.ts +++ b/tests/spec/nullable-parent-with-nullable-children/basic.test.ts @@ -8,9 +8,7 @@ describe("nullable-parent-with-nullable-children", async () => { let tmpdir = ""; beforeAll(async () => { - tmpdir = await fs.mkdtemp( - path.join(os.tmpdir(), "swagger-typescript-api"), - ); + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); }); afterAll(async () => { From 6db96156cd5b5e1c6cac7cba35f7762efb777c63 Mon Sep 17 00:00:00 2001 From: Adam Banky Date: Thu, 11 Dec 2025 00:46:11 +0100 Subject: [PATCH 4/4] fix: adding changeset --- .changeset/giant-hats-work.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/giant-hats-work.md diff --git a/.changeset/giant-hats-work.md b/.changeset/giant-hats-work.md new file mode 100644 index 000000000..4db4d2860 --- /dev/null +++ b/.changeset/giant-hats-work.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": patch +--- + +Fixed incorrect null handling for nullable objects with nullable properties (#533)