Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/giant-hats-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"swagger-typescript-api": patch
---

Fixed incorrect null handling for nullable objects with nullable properties (#533)
28 changes: 20 additions & 8 deletions src/schema-parser/schema-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
74 changes: 39 additions & 35 deletions tests/__snapshots__/extended.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch name>\`. */
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/<branch name>\`. */
ref?: CodeScanningAlertRef;
/** State of a code scanning alert. */
state?: CodeScanningAlertState;
}[]
| null;

/** The full Git reference, formatted as \`refs/heads/<branch name>\`. */
export type CodeScanningAlertRef = string;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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\`.
Expand Down Expand Up @@ -32356,7 +32360,7 @@ export type TeamSimple = {
* @example "https://api.github.com/organizations/1/team/1"
*/
url: string;
};
} | null;

export type TeamsAddMemberLegacyData = any;

Expand Down
74 changes: 39 additions & 35 deletions tests/__snapshots__/simple.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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/<branch name>\`. */
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/<branch name>\`. */
ref?: CodeScanningAlertRef;
/** State of a code scanning alert. */
state?: CodeScanningAlertState;
}[]
| null;

/** The full Git reference, formatted as \`refs/heads/<branch name>\`. */
export type CodeScanningAlertRef = string;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -16031,7 +16033,7 @@ export type SimpleUser = {
* @example "https://api.github.com/users/octocat"
*/
url: string;
};
} | null;

/**
* Stargazer
Expand Down Expand Up @@ -16739,7 +16741,7 @@ export type TeamSimple = {
* @example "https://api.github.com/organizations/1/team/1"
*/
url: string;
};
} | null;

/**
* Thread
Expand Down Expand Up @@ -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<GistSimple, BasicError | ValidationError>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export type MyObject4 = Record<
{
content?: string;
filename?: string | null;
}
} | null
>;
"
`;
Original file line number Diff line number Diff line change
@@ -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;
}
"
`;
33 changes: 33 additions & 0 deletions tests/spec/nullable-parent-with-nullable-children/basic.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading