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
14 changes: 14 additions & 0 deletions .changeset/import-file-extension-and-type-only-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"swagger-typescript-api": minor
---

Add `importFileExtension` and `typeOnlyImports` options

`importFileExtension` (`""` | `".js"` | `".ts"`) appends a file extension to
generated relative imports, for projects using `moduleResolution: node16`/`nodenext`
(`.js`) or `allowImportingTsExtensions` (`.ts`).

`typeOnlyImports` emits `import type` for type-only imports (and inline `type` on
mixed imports such as the http-client import, where `HttpClient` stays a value
import) for projects using `verbatimModuleSyntax` / `isolatedModules`. `ContentType`
is only marked `type` for `enumStyle: "union"`, where it is a pure type.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
/docs/
/node_modules/
/tmp-issue-463-run/
/.idea/
/.serena/
18 changes: 18 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,18 @@ const generateCommand = defineCommand({
'enum output style: "enum" (default), "union" (T1 | T2 | TN), "const" (as const object + type alias), or "const-enum" (const enum)',
default: codeGenBaseConfig.enumStyle,
},
"import-file-extension": {
type: "string",
description:
'extension appended to generated relative imports: "" (default), ".js" (moduleResolution node16/nodenext), or ".ts" (allowImportingTsExtensions)',
default: codeGenBaseConfig.importFileExtension,
},
"type-only-imports": {
type: "boolean",
description:
"emit `import type` / inline `type` for type-only imports (verbatimModuleSyntax / isolatedModules)",
default: codeGenBaseConfig.typeOnlyImports,
},
"http-client": {
type: "string",
description: `http client type (possible values: ${Object.values(
Expand Down Expand Up @@ -349,6 +361,12 @@ const generateCommand = defineCommand({
| "const"
| "const-enum"
| undefined,
importFileExtension: args["import-file-extension"] as

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a CLI user passes a value other than "", .js, or .ts, this flag accepts it and the cast does not validate it at runtime. Validate and reject unsupported extensions before passing the value to generateApi, otherwise generation can produce imports that do not point to generated files.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At index.ts, line 364:

<comment>When a CLI user passes a value other than `""`, `.js`, or `.ts`, this flag accepts it and the cast does not validate it at runtime. Validate and reject unsupported extensions before passing the value to `generateApi`, otherwise generation can produce imports that do not point to generated files.</comment>

<file context>
@@ -349,6 +361,12 @@ const generateCommand = defineCommand({
         | "const"
         | "const-enum"
         | undefined,
+      importFileExtension: args["import-file-extension"] as
+        | ""
+        | ".js"
</file context>

| ""
| ".js"
| ".ts"
| undefined,
typeOnlyImports: args["type-only-imports"],
httpClientType:
args["http-client"] || args.axios
? HTTP_CLIENT.AXIOS
Expand Down
10 changes: 10 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export class CodeGenConfig {
enumStyle: "enum" | "union" | "const" | "const-enum" = "enum";
/** @deprecated Use enumStyle: "union" instead */
generateUnionEnums = false;
/** CLI flag. Extension appended to generated relative imports: "" (default), ".js", or ".ts". */
importFileExtension: "" | ".js" | ".ts" = "";
/** CLI flag. Emit `import type` / inline `type` for type-only imports. */
typeOnlyImports = false;
/** CLI flag */
addReadonly = false;
enumNamesAsValues = false;
Expand Down Expand Up @@ -466,6 +470,12 @@ export class CodeGenConfig {
>,
) => {
objectAssign(this, update);
this.importFileExtension ??= "";
if (!["", ".js", ".ts"].includes(this.importFileExtension)) {
throw new Error(
`Invalid \`importFileExtension\` value "${this.importFileExtension}". Expected "", ".js", or ".ts".`,
);
}
if (this.enumNamesAsValues) {
this.extractEnums = true;
}
Expand Down
2 changes: 1 addition & 1 deletion templates/default/route-types.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const dataContracts = config.modular ? _.map(modelTypes, "name") : [];
%>

<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
import <%~ config.typeOnlyImports ? "type " : "" %>{ <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %><%~ config.importFileExtension %>"
<% } %>

<%
Expand Down
13 changes: 7 additions & 6 deletions templates/modular/api.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ const { _, pascalCase, require } = utils;
const apiClassName = pascalCase(route.moduleName);
const routes = route.routes;
const dataContracts = _.map(modelTypes, "name");
const importExt = config.importFileExtension;
const typeModifier = config.typeOnlyImports ? "type " : "";
// ContentType is a pure type only for the "union" style; otherwise it is a runtime value.
const contentTypeSpecifier = config.enumStyle === "union" ? "type ContentType" : "ContentType";
const httpClientSpecifiers = ["HttpClient", `${typeModifier}RequestParams`, contentTypeSpecifier, `${typeModifier}HttpResponse`].join(", ");
%>

<% if (config.httpClientType === config.constants.HTTP_CLIENT.AXIOS) { %> import type { AxiosRequestConfig, AxiosResponse } from "axios"; <% } %>

<% if (config.enumStyle === "union") { %>
import { HttpClient, RequestParams, type ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>";
<% } else { %>
import { HttpClient, RequestParams, ContentType, HttpResponse } from "./<%~ config.fileNames.httpClient %>";
<% } %>
import { <%~ httpClientSpecifiers %> } from "./<%~ config.fileNames.httpClient %><%~ importExt %>";
<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
import <%~ typeModifier %>{ <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %><%~ importExt %>"
<% } %>

export class <%= apiClassName %><SecurityDataType = unknown><% if (!config.singleHttpClient) { %> extends HttpClient<SecurityDataType> <% } %> {
Expand Down
2 changes: 1 addition & 1 deletion templates/modular/route-types.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const dataContracts = config.modular ? _.map(modelTypes, "name") : [];

%>
<% if (dataContracts.length) { %>
import { <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %>"
import <%~ config.typeOnlyImports ? "type " : "" %>{ <%~ dataContracts.join(", ") %> } from "./<%~ config.fileNames.dataContracts %><%~ config.importFileExtension %>"
<% } %>

export namespace <%~ pascalCase(moduleName) %> {
Expand Down
173 changes: 173 additions & 0 deletions tests/spec/import-file-extension/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
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("import-file-extension", async () => {
let tmpdir = "";

beforeAll(async () => {
tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api"));
});

afterAll(async () => {
await fs.rm(tmpdir, { recursive: true });
});

const generate = async (
fileName: string,
options: Partial<Parameters<typeof generateApi>[0]>,
) => {
await generateApi({
fileName,
input: path.resolve(import.meta.dirname, "schema.json"),
output: tmpdir,
silent: true,
modular: true,
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
cleanOutput: false,
...options,
});
return fs.readFile(path.join(tmpdir, "Api.ts"), { encoding: "utf8" });
};

const generateRouteTypes = async (
fileName: string,
options: Partial<Parameters<typeof generateApi>[0]>,
) => {
await generateApi({
fileName,
input: path.resolve(import.meta.dirname, "schema.json"),
output: tmpdir,
silent: true,
modular: true,
cleanOutput: false,
generateRouteTypes: true,
generateClient: false,
...options,
});
return fs.readFile(path.join(tmpdir, "ApiRoute.ts"), { encoding: "utf8" });
};

test('appends ".js" to relative imports', async () => {
const api = await generate("js-ext", { importFileExtension: ".js" });

expect(api).toContain('from "./data-contracts.js"');
expect(api).toContain('from "./http-client.js"');
});

test('appends ".ts" to relative imports', async () => {
const api = await generate("ts-ext", { importFileExtension: ".ts" });

expect(api).toContain('from "./data-contracts.ts"');
expect(api).toContain('from "./http-client.ts"');
});

test("defaults to no extension when option is unset", async () => {
const api = await generate("no-ext", {});

expect(api).toContain('from "./data-contracts"');
expect(api).toContain('from "./http-client"');
expect(api).not.toContain("data-contracts.js");
expect(api).not.toContain("data-contracts.ts");
});

test("emits whole-block type import for data-contracts when typeOnlyImports", async () => {
const api = await generate("type-only", { typeOnlyImports: true });

expect(api).toMatch(/import type \{[^}]*\} from "\.\/data-contracts"/);
});

test("marks type-only http-client specifiers inline, keeps HttpClient a value import", async () => {
const api = await generate("type-only-http", { typeOnlyImports: true });

const httpImport = api
.split("\n")
.find((line) => line.includes('from "./http-client"'));

expect(httpImport).toBeDefined();
expect(httpImport).toContain("type RequestParams");
// HttpClient is a runtime class (extended/instantiated) - never type-only
expect(httpImport).not.toContain("type HttpClient");
expect(httpImport).not.toMatch(/import type \{/);
});

test("does not mark ContentType as type for runtime enum styles", async () => {
const api = await generate("type-only-enum", {
typeOnlyImports: true,
enumStyle: "enum",
});

expect(api).not.toContain("type ContentType");
});

test("never imports ContentType as a runtime value for union enum style", async () => {
const api = await generate("type-only-union", {
typeOnlyImports: true,
enumStyle: "union",
});

const httpImport = api
.split("\n")
.find((line) => line.includes('from "./http-client"'));

expect(httpImport).toBeDefined();
// In union mode ContentType is a pure type: procedure calls use string
// literals instead, so it is either marked `type` or dropped as unused -
// it must never appear as a bare runtime import.
expect(httpImport).not.toMatch(/(?<!type )\bContentType\b/);
// The mixed http-client import still marks the type-only specifiers inline.
expect(httpImport).toContain("type RequestParams");
});

test("combines extension and type-only imports", async () => {
const api = await generate("combined", {
importFileExtension: ".js",
typeOnlyImports: true,
});

expect(api).toMatch(/import type \{[^}]*\} from "\.\/data-contracts\.js"/);
expect(api).toContain('from "./http-client.js"');
});

test("rejects an unsupported importFileExtension value", async () => {
await expect(
generateApi({
fileName: "invalid-ext",
input: path.resolve(import.meta.dirname, "schema.json"),
output: tmpdir,
silent: true,
cleanOutput: false,
importFileExtension: ".mjs" as ".js",
}),
).rejects.toThrow(/importFileExtension/);
});

test("treats an explicit undefined importFileExtension as no extension", async () => {
const api = await generate("undef-ext", {
importFileExtension: undefined,
});

expect(api).toContain('from "./data-contracts"');
expect(api).not.toContain("data-contracts.js");
});

test("appends extension to route-type imports", async () => {
const routeTypes = await generateRouteTypes("route-types-ext", {
importFileExtension: ".js",
});

expect(routeTypes).toContain('from "./data-contracts.js"');
});

test("emits type-only route-type imports when typeOnlyImports", async () => {
const routeTypes = await generateRouteTypes("route-types-type-only", {
importFileExtension: ".js",
typeOnlyImports: true,
});

expect(routeTypes).toMatch(
/import type \{[^}]*\} from "\.\/data-contracts\.js"/,
);
});
});
Loading