Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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