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) 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..3a73d711e --- /dev/null +++ b/tests/spec/nullable-parent-with-nullable-children/basic.test.ts @@ -0,0 +1,33 @@ +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" + } + } + } + } + } +}