From 34225d9181901d718395be0185ec536635bf5416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D0=B0=D0=BB=D1=8C=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Thu, 3 Sep 2026 16:57:22 +0500 Subject: [PATCH 1/3] Add importFileExtension and typeOnlyImports options Generated relative imports were extensionless and value-only, which breaks moduleResolution node16/nodenext (needs .js), allowImportingTsExtensions (needs .ts), and verbatimModuleSyntax (needs import type). Users had to post-process the output by hand. - importFileExtension ("" | ".js" | ".ts") appends an extension to generated relative imports. - typeOnlyImports emits `import type` for the wholly type-only data-contracts import and inline `type` for the mixed http-client import, keeping HttpClient a value import. ContentType is marked `type` only for enumStyle "union". Both are exposed as CLI flags. Defaults preserve current output. Closes #1829 Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rt-file-extension-and-type-only-imports.md | 14 + index.ts | 18 + src/configuration.ts | 4 + templates/default/route-types.ejs | 2 +- templates/modular/api.ejs | 13 +- templates/modular/route-types.ejs | 2 +- .../spec/import-file-extension/basic.test.ts | 114 ++ tests/spec/import-file-extension/schema.json | 1099 +++++++++++++++++ types/index.ts | 10 + 9 files changed, 1268 insertions(+), 8 deletions(-) create mode 100644 .changeset/import-file-extension-and-type-only-imports.md create mode 100644 tests/spec/import-file-extension/basic.test.ts create mode 100644 tests/spec/import-file-extension/schema.json diff --git a/.changeset/import-file-extension-and-type-only-imports.md b/.changeset/import-file-extension-and-type-only-imports.md new file mode 100644 index 000000000..75fcdf8b7 --- /dev/null +++ b/.changeset/import-file-extension-and-type-only-imports.md @@ -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. diff --git a/index.ts b/index.ts index 5762f0423..70ef1a359 100644 --- a/index.ts +++ b/index.ts @@ -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( @@ -349,6 +361,12 @@ const generateCommand = defineCommand({ | "const" | "const-enum" | undefined, + importFileExtension: args["import-file-extension"] as + | "" + | ".js" + | ".ts" + | undefined, + typeOnlyImports: args["type-only-imports"], httpClientType: args["http-client"] || args.axios ? HTTP_CLIENT.AXIOS diff --git a/src/configuration.ts b/src/configuration.ts index 6462e9d85..7951d07d1 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -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; diff --git a/templates/default/route-types.ejs b/templates/default/route-types.ejs index 6590a64fb..c74b6cf5c 100644 --- a/templates/default/route-types.ejs +++ b/templates/default/route-types.ejs @@ -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 %>" <% } %> <% diff --git a/templates/modular/api.ejs b/templates/modular/api.ejs index ab668ab89..6ee00a503 100644 --- a/templates/modular/api.ejs +++ b/templates/modular/api.ejs @@ -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 %><% if (!config.singleHttpClient) { %> extends HttpClient <% } %> { diff --git a/templates/modular/route-types.ejs b/templates/modular/route-types.ejs index 16ec9dcb1..8c9c3d14e 100644 --- a/templates/modular/route-types.ejs +++ b/templates/modular/route-types.ejs @@ -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) %> { diff --git a/tests/spec/import-file-extension/basic.test.ts b/tests/spec/import-file-extension/basic.test.ts new file mode 100644 index 000000000..46fc95d76 --- /dev/null +++ b/tests/spec/import-file-extension/basic.test.ts @@ -0,0 +1,114 @@ +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[0]>, + ) => { + await generateApi({ + fileName, + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + modular: true, + cleanOutput: false, + ...options, + }); + return fs.readFile(path.join(tmpdir, "Api.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(/(? { + 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"'); + }); +}); diff --git a/tests/spec/import-file-extension/schema.json b/tests/spec/import-file-extension/schema.json new file mode 100644 index 000000000..6841c8218 --- /dev/null +++ b/tests/spec/import-file-extension/schema.json @@ -0,0 +1,1099 @@ +{ + "swagger": "2.0", + "info": { + "description": "This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.", + "version": "1.0.0", + "title": "Swagger Petstore", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache-2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "host": "petstore.swagger.io", + "basePath": "/v2", + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "http://swagger.io" + } + }, + { + "name": "store", + "description": "Access to Petstore orders" + }, + { + "name": "user", + "description": "Operations about user", + "externalDocs": { + "description": "Find out more about our store", + "url": "http://swagger.io" + } + } + ], + "schemes": ["http"], + "paths": { + "api/v1/pet": { + "post": { + "tags": ["pet"], + "summary": "Add a new pet to the store", + "description": "", + "operationId": "addPet", + "consumes": ["application/json", "application/xml"], + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Pet object that needs to be added to the store", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-contentType": "application/json", + "x-accepts": "application/json" + }, + "put": { + "tags": ["pet"], + "summary": "Update an existing pet", + "description": "", + "operationId": "updatePet", + "consumes": ["application/json", "application/xml"], + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Pet object that needs to be added to the store", + "required": true, + "schema": { + "$ref": "#/definitions/Pet" + } + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + }, + "405": { + "description": "Validation exception" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-contentType": "application/json", + "x-accepts": "application/json" + } + }, + "api/v1/pet/findByStatus": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by status", + "description": "Multiple status values can be provided with comma separated strings", + "operationId": "findPetsByStatus", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": true, + "type": "array", + "items": { + "type": "string", + "default": "available", + "enum": ["available", "pending", "sold"] + }, + "collectionFormat": "csv" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "400": { + "description": "Invalid status value" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-accepts": "application/json" + } + }, + "api/v1/pet/findByTags": { + "get": { + "tags": ["pet"], + "summary": "Finds Pets by tags", + "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", + "operationId": "findPetsByTags", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": true, + "type": "array", + "items": { + "type": "string" + }, + "collectionFormat": "csv" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + }, + "400": { + "description": "Invalid tag value" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "deprecated": true, + "x-accepts": "application/json" + } + }, + "api/v1/pet/{petId}": { + "get": { + "tags": ["pet"], + "summary": "Find pet by ID", + "description": "Returns a single pet", + "operationId": "getPetById", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to return", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Pet" + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Pet not found" + } + }, + "security": [ + { + "api_key": [] + } + ], + "x-accepts": "application/json" + }, + "post": { + "tags": ["pet"], + "summary": "Updates a pet in the store with form data", + "description": "", + "operationId": "updatePetWithForm", + "consumes": ["application/x-www-form-urlencoded"], + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "name": "name", + "in": "formData", + "description": "Updated name of the pet", + "required": false, + "type": "string" + }, + { + "name": "status", + "in": "formData", + "description": "Updated status of the pet", + "required": false, + "type": "string" + } + ], + "responses": { + "405": { + "description": "Invalid input" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-contentType": "application/x-www-form-urlencoded", + "x-accepts": "application/json" + }, + "delete": { + "tags": ["pet"], + "summary": "Deletes a pet", + "description": "", + "operationId": "deletePet", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "api_key", + "in": "header", + "required": false, + "type": "string" + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "type": "integer", + "format": "int64" + } + ], + "responses": { + "400": { + "description": "Invalid pet value" + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-accepts": "application/json" + } + }, + "api/v1/pet/{petId}/uploadImage": { + "post": { + "tags": ["pet"], + "summary": "uploads an image", + "description": "", + "operationId": "uploadFile", + "consumes": ["multipart/form-data"], + "produces": ["application/json"], + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "type": "integer", + "format": "int64" + }, + { + "name": "additionalMetadata", + "in": "formData", + "description": "Additional data to pass to server", + "required": false, + "type": "string" + }, + { + "name": "file", + "in": "formData", + "description": "file to upload", + "required": false, + "type": "file" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/ApiResponse" + } + } + }, + "security": [ + { + "petstore_auth": ["write:pets", "read:pets"] + } + ], + "x-contentType": "multipart/form-data", + "x-accepts": "application/json" + } + }, + "api/v1/store/inventory": { + "get": { + "tags": ["store"], + "summary": "Returns pet inventories by status", + "description": "Returns a map of status codes to quantities", + "operationId": "getInventory", + "produces": ["application/json"], + "parameters": [], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + }, + "security": [ + { + "api_key": [] + } + ], + "x-accepts": "application/json" + } + }, + "api/v1/store/order": { + "post": { + "tags": ["store"], + "summary": "Place an order for a pet", + "description": "", + "operationId": "placeOrder", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "order placed for purchasing the pet", + "required": true, + "schema": { + "$ref": "#/definitions/Order" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Order" + } + }, + "400": { + "description": "Invalid Order" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + } + }, + "api/v1/store/order/{orderId}": { + "get": { + "tags": ["store"], + "summary": "Find purchase order by ID", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generated exceptions", + "operationId": "getOrderById", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of pet that needs to be fetched", + "required": true, + "type": "integer", + "maximum": 5, + "minimum": 1, + "format": "int64" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/Order" + } + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + }, + "x-accepts": "application/json" + }, + "delete": { + "tags": ["store"], + "summary": "Delete purchase order by ID", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", + "operationId": "deleteOrder", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + } + }, + "x-accepts": "application/json" + } + }, + "api/v1/user": { + "post": { + "tags": ["user"], + "summary": "Create user", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "Created user object", + "required": true, + "schema": { + "$ref": "#/definitions/User" + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + } + }, + "api/v1/user/createWithArray": { + "post": { + "tags": ["user"], + "summary": "Creates list of users with given input array", + "description": "", + "operationId": "createUsersWithArrayInput", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "List of user object", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + } + }, + "api/v1/user/createWithList": { + "post": { + "tags": ["user"], + "summary": "Creates list of users with given input array", + "description": "", + "operationId": "createUsersWithListInput", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "List of user object", + "required": true, + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/User" + } + } + } + ], + "responses": { + "default": { + "description": "successful operation" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + } + }, + "api/v1/user/login": { + "get": { + "tags": ["user"], + "summary": "Logs user into the system", + "description": "", + "operationId": "loginUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": true, + "type": "string" + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "type": "integer", + "format": "int32", + "description": "calls per hour allowed by the user" + }, + "X-Expires-After": { + "type": "string", + "format": "date-time", + "description": "date in UTC when toekn expires" + } + }, + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid username/password supplied" + } + }, + "x-accepts": "application/json" + } + }, + "api/v1/user/logout": { + "get": { + "tags": ["user"], + "summary": "Logs out current logged in user session", + "description": "", + "operationId": "logoutUser", + "produces": ["application/xml", "application/json"], + "parameters": [], + "responses": { + "default": { + "description": "successful operation" + } + }, + "x-accepts": "application/json" + } + }, + "api/v1/user/{username}": { + "get": { + "tags": ["user"], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing.", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/User" + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-accepts": "application/json" + }, + "put": { + "tags": ["user"], + "summary": "Updated user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "description": "Updated user object", + "required": true, + "schema": { + "$ref": "#/definitions/User" + } + } + ], + "responses": { + "400": { + "description": "Invalid user supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + }, + "delete": { + "tags": ["user"], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-accepts": "application/json" + } + }, + "api/v1/{username}": { + "get": { + "tags": ["user"], + "summary": "Get user by user name", + "description": "", + "operationId": "getUserByName", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be fetched. Use user1 for testing.", + "required": true, + "type": "string" + } + ], + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/User" + } + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-accepts": "application/json" + }, + "put": { + "tags": ["user"], + "summary": "Updated user", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "description": "Updated user object", + "required": true, + "schema": { + "$ref": "#/definitions/User" + } + } + ], + "responses": { + "400": { + "description": "Invalid user supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-contentType": "application/json", + "x-accepts": "application/json" + }, + "delete": { + "tags": ["user"], + "summary": "Delete user", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "produces": ["application/xml", "application/json"], + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "type": "string" + } + ], + "responses": { + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + } + }, + "x-accepts": "application/json" + } + } + }, + "securityDefinitions": { + "petstore_auth": { + "type": "oauth2", + "authorizationUrl": "http://petstore.swagger.io/api/v1/oauth/dialog", + "flow": "implicit", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + }, + "definitions": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "petId": { + "type": "integer", + "format": "int64" + }, + "quantity": { + "type": "integer", + "format": "int32" + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "enum": ["placed", "approved", "delivered"] + }, + "complete": { + "type": "boolean", + "default": false + } + }, + "title": "Pet Order", + "xml": { + "name": "Order" + }, + "description": "An order for a pets from the pet store", + "example": { + "petId": 6, + "quantity": 1, + "id": 0, + "shipDate": "2000-01-23T04:56:07.000+00:00", + "complete": false, + "status": "placed" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "title": "Pet category", + "xml": { + "name": "Category" + }, + "description": "A category for a pet", + "example": { + "name": "name", + "id": 6 + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "username": { + "type": "string" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "userStatus": { + "type": "integer", + "format": "int32", + "description": "User Status" + } + }, + "title": "a User", + "xml": { + "name": "User" + }, + "description": "A User who is purchasing from the pet store", + "example": { + "firstName": "firstName", + "lastName": "lastName", + "password": "password", + "userStatus": 6, + "phone": "phone", + "id": 0, + "email": "email", + "username": "username" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "title": "Pet Tag", + "xml": { + "name": "Tag" + }, + "description": "A tag for a pet", + "example": { + "name": "name", + "id": 1 + } + }, + "PetNames": { + "type": "string", + "enum": ["Fluffy Hero", "Piggy Po", "Swagger Typescript Api"] + }, + "PetIds": { + "type": "integer", + "enum": [10, 20, 30, 40] + }, + "Pet": { + "type": "object", + "required": ["name", "photoUrls"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "category": { + "$ref": "#/definitions/Category" + }, + "name": { + "type": "string", + "example": "doggie" + }, + "photoUrls": { + "type": "array", + "xml": { + "name": "photoUrl", + "wrapped": true + }, + "items": { + "type": "string" + } + }, + "tags": { + "type": "array", + "xml": { + "name": "tag", + "wrapped": true + }, + "items": { + "$ref": "#/definitions/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": ["available", "pending", "sold"] + } + }, + "title": "a Pet", + "xml": { + "name": "Pet" + }, + "description": "A pet for sale in the pet store", + "example": { + "photoUrls": ["photoUrls", "photoUrls"], + "name": "doggie", + "id": 0, + "category": { + "name": "name", + "id": 6 + }, + "tags": [ + { + "name": "name", + "id": 1 + }, + { + "name": "name", + "id": 1 + } + ], + "status": "available" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "title": "An uploaded response", + "description": "Describes the result of uploading an image resource", + "example": { + "code": 0, + "type": "type", + "message": "message" + } + }, + "Amount": { + "type": "object", + "required": ["currency", "value"], + "properties": { + "value": { + "type": "number", + "format": "double", + "description": "some description\n", + "minimum": 0.01, + "maximum": 1000000000000000 + }, + "currency": { + "$ref": "#/definitions/Currency" + } + }, + "description": "some description\n" + }, + "Currency": { + "type": "string", + "pattern": "^[A-Z]{3,3}$", + "description": "some description\n" + } + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "http://swagger.io" + } +} diff --git a/types/index.ts b/types/index.ts index d2384035e..f6552098a 100644 --- a/types/index.ts +++ b/types/index.ts @@ -535,6 +535,16 @@ export interface GenerateApiConfiguration { enumStyle: "enum" | "union" | "const" | "const-enum"; /** @deprecated Use enumStyle: "union" instead */ generateUnionEnums: boolean; + /** + * file extension appended to generated relative imports (e.g. `./data-contracts` -> `./data-contracts.js`). + * Use ".js" for `moduleResolution: node16/nodenext`, ".ts" for `allowImportingTsExtensions`, or "" (default) for none. + */ + importFileExtension: "" | ".js" | ".ts"; + /** + * emit `import type` (and inline `type` on mixed imports) for type-only imports. + * Useful for `verbatimModuleSyntax` / `isolatedModules`. + */ + typeOnlyImports: boolean; /** parsed swagger schema */ swaggerSchema: OpenAPI.Document; /** original swagger schema */ From 140d613e14ffd9daf8bd4ffc269ad96cb2e789ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D0=B0=D0=BB=D1=8C=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Thu, 3 Sep 2026 17:12:00 +0500 Subject: [PATCH 2/3] Ignore .idea and .serena directories Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index bd434279f..05982c9d7 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /docs/ /node_modules/ /tmp-issue-463-run/ +/.idea/ +/.serena/ From 4f0dcb219f355bea7038b058d26995560196e178 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D0=BE=D0=B2=D0=B0=D0=BB=D1=8C=D0=BA=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=9A=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 4 Sep 2026 14:46:12 +0500 Subject: [PATCH 3/3] Validate importFileExtension and test route-type imports Address PR review feedback: - Reject unsupported importFileExtension values at runtime (in config update), so CLI/config-file callers that bypass the type get a clear error instead of broken imports. Explicit undefined normalizes to "". - Add route-type coverage (generateRouteTypes) asserting the extension and type-only import behavior of route-types.ejs. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/configuration.ts | 6 ++ .../spec/import-file-extension/basic.test.ts | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/src/configuration.ts b/src/configuration.ts index 7951d07d1..17dd5f78c 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -470,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; } diff --git a/tests/spec/import-file-extension/basic.test.ts b/tests/spec/import-file-extension/basic.test.ts index 46fc95d76..5ea3251d1 100644 --- a/tests/spec/import-file-extension/basic.test.ts +++ b/tests/spec/import-file-extension/basic.test.ts @@ -31,6 +31,24 @@ describe("import-file-extension", async () => { return fs.readFile(path.join(tmpdir, "Api.ts"), { encoding: "utf8" }); }; + const generateRouteTypes = async ( + fileName: string, + options: Partial[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" }); @@ -111,4 +129,45 @@ describe("import-file-extension", async () => { 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"/, + ); + }); });