diff --git a/.changeset/fix-operationid-starting-with-number.md b/.changeset/fix-operationid-starting-with-number.md new file mode 100644 index 000000000..c5449fa6a --- /dev/null +++ b/.changeset/fix-operationid-starting-with-number.md @@ -0,0 +1,13 @@ +--- +"swagger-typescript-api": patch +--- + +Fix TypeScript generation failure for operationIds starting with numbers + +**What:** Fixed an issue where operationIds starting with numbers (e.g., "123getUser") would cause TypeScript generation to fail due to invalid identifier names. + +**Why:** OperationIds that start with numbers are not valid JavaScript identifiers, causing syntax errors in the generated TypeScript code. + +**How:** Modified the template logic to quote property names for invalid identifiers. OperationIds starting with numbers are now generated as quoted properties (e.g., `"123GetUser": ...`) instead of unquoted invalid identifiers. + +This resolves GitHub issue #952. diff --git a/templates/base/route-type.ejs b/templates/base/route-type.ejs index 14c5a23bc..937c41911 100644 --- a/templates/base/route-type.ejs +++ b/templates/base/route-type.ejs @@ -4,6 +4,7 @@ const { _, pascalCase, require } = utils; const { query, payload, pathParams, headers } = route.request; const routeDocs = includeFile("@base/route-docs", { config, route, utils }); +const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); const routeNamespace = pascalCase(route.routeName.usage); %> @@ -14,7 +15,7 @@ const routeNamespace = pascalCase(route.routeName.usage); <%~ routeDocs.lines %> */ -export namespace <%~ routeNamespace %> { +export namespace <% if (isValidIdentifier(routeNamespace)) { %><%~ routeNamespace %><% } else { %>"<%~ routeNamespace %>"<% } %> { export type RequestParams = <%~ (pathParams && pathParams.type) || '{}' %>; export type RequestQuery = <%~ (query && query.type) || '{}' %>; export type RequestBody = <%~ (payload && payload.type) || 'never' %>; diff --git a/templates/default/procedure-call.ejs b/templates/default/procedure-call.ejs index 426aba34e..90af47bdd 100644 --- a/templates/default/procedure-call.ejs +++ b/templates/default/procedure-call.ejs @@ -79,6 +79,8 @@ const describeReturnType = () => { } } +const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); + %> /** <%~ routeDocs.description %> @@ -88,7 +90,7 @@ const describeReturnType = () => { <%~ routeDocs.lines %> */ -<%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => +<% if (isValidIdentifier(route.routeName.usage)) { %><%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ route.routeName.usage %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => <%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({ path: `<%~ path %>`, method: '<%~ _.upperCase(method) %>', diff --git a/templates/modular/procedure-call.ejs b/templates/modular/procedure-call.ejs index 1af59ac8d..90af47bdd 100644 --- a/templates/modular/procedure-call.ejs +++ b/templates/modular/procedure-call.ejs @@ -79,6 +79,8 @@ const describeReturnType = () => { } } +const isValidIdentifier = (name) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name); + %> /** <%~ routeDocs.description %> @@ -88,7 +90,7 @@ const describeReturnType = () => { <%~ routeDocs.lines %> */ -<%~ route.routeName.usage %> = (<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => +<% if (isValidIdentifier(route.routeName.usage)) { %><%~ route.routeName.usage %><%~ route.namespace ? ': ' : ' = ' %><% } else { %>"<%~ route.routeName.usage %>"<%~ route.namespace ? ': ' : ' = ' %><% } %>(<%~ wrapperArgs %>)<%~ config.toJS ? `: ${describeReturnType()}` : "" %> => <%~ config.singleHttpClient ? 'this.http.request' : 'this.request' %><<%~ type %>, <%~ errorType %>>({ path: `<%~ path %>`, method: '<%~ _.upperCase(method) %>', @@ -98,4 +100,4 @@ const describeReturnType = () => { <%~ bodyContentKindTmpl ? `type: ${bodyContentKindTmpl},` : '' %> <%~ responseFormatTmpl ? `format: ${responseFormatTmpl},` : '' %> ...<%~ _.get(requestConfigParam, "name") %>, - }) + })<%~ route.namespace ? ',' : '' %> diff --git a/tests/spec/operationId-starting-with-number/__snapshots__/basic.test.ts.snap b/tests/spec/operationId-starting-with-number/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..8a01e7756 --- /dev/null +++ b/tests/spec/operationId-starting-with-number/__snapshots__/basic.test.ts.snap @@ -0,0 +1,341 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`operationId starting with number > should handle operationIds that start with numbers 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 ## + * --------------------------------------------------------------- + */ + + + + + + + + + export namespace Users { + +/** + * No description + * @name 123GetUser + * @summary Get user + * @request GET:/users +*/ +export namespace "123GetUser" { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = { + id?: number, + name?: string, + +}; +} + } + + export namespace Posts { + +/** + * No description + * @name 456CreatePost + * @summary Create post + * @request POST:/posts +*/ +export namespace "456CreatePost" { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = { + title?: string, + content?: string, + +}; + export type RequestHeaders = {}; + export type ResponseBody = { + id?: number, + title?: string, + +}; +} + } + + + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to \`true\` for call \`securityWorker\` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit + + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: (securityData: SecurityDataType | null) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = ""; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: 'same-origin', + headers: {}, + redirect: 'follow', + referrerPolicy: 'no-referrer', + } + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + } + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return \`\${encodedKey}=\${encodeURIComponent(typeof value === "number" ? value : \`\${value}\`)}\`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter((key) => "undefined" !== typeof query[key]); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? \`?\${queryString}\` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.JsonApi]: (input:any) => input !== null && (typeof input === "object" || typeof input === "string") ? JSON.stringify(input) : input, + [ContentType.Text]: (input:any) => input !== null && typeof input !== "string" ? JSON.stringify(input) : input, + [ContentType.FormData]: (input: any) => + Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob ? + property : + typeof property === "object" && property !== null ? + JSON.stringify(property) : + \`\${property}\` + ); + return formData; + }, new FormData()), + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + } + + protected mergeRequestParams(params1: RequestParams, params2?: RequestParams): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = (cancelToken: CancelToken): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + } + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken) + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + } + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise> => { + const secureParams = ((typeof secure === 'boolean' ? secure : this.baseApiParams.secure) && this.securityWorker && await this.securityWorker(this.securityData)) || {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + \`\${baseUrl || this.baseUrl || ""}\${path}\${queryString ? \`?\${queryString}\` : ""}\`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData ? { "Content-Type": type } : {}), + }, + signal: (cancelToken ? this.createAbortSignal(cancelToken) : requestParams.signal) || null, + body: typeof body === "undefined" || body === null ? null : payloadFormatter(body), + } + ).then(async (response) => { + const r = response.clone() as HttpResponse; + r.data = (null as unknown) as T; + r.error = (null as unknown) as E; + + const data = !responseFormat ? r : await response[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data; + }); + }; +} + + + +/** +* @title Test API with operationId starting with number +* @version 1.0.0 +*/ +export class Api extends HttpClient { + + + + + users = { + + /** + * No description + * + * @name 123GetUser + * @summary Get user + * @request GET:/users + */ +"123GetUser": (params: RequestParams = {}) => + this.request<{ + id?: number, + name?: string, + +}, any>({ + path: \`/users\`, + method: 'GET', + format: "json", ...params, + }), + } + posts = { + + /** + * No description + * + * @name 456CreatePost + * @summary Create post + * @request POST:/posts + */ +"456CreatePost": (data: { + title?: string, + content?: string, + +}, params: RequestParams = {}) => + this.request<{ + id?: number, + title?: string, + +}, any>({ + path: \`/posts\`, + method: 'POST', + body: data, type: ContentType.Json, format: "json", ...params, + }), + } + } +" +`; diff --git a/tests/spec/operationId-starting-with-number/basic.test.ts b/tests/spec/operationId-starting-with-number/basic.test.ts new file mode 100644 index 000000000..dfc6a571e --- /dev/null +++ b/tests/spec/operationId-starting-with-number/basic.test.ts @@ -0,0 +1,43 @@ +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("operationId starting with number", async () => { + let tmpdir: string; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("should handle operationIds that start with numbers", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + generateRouteTypes: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + // Should contain quoted property names for invalid identifiers + expect(content).toContain('"123GetUser"'); + expect(content).toContain('"456CreatePost"'); + // Should not contain unquoted invalid identifiers + expect(content).not.toContain("123GetUser:"); + expect(content).not.toContain("456CreatePost:"); + // Should be valid TypeScript (no syntax errors) + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/operationId-starting-with-number/schema.json b/tests/spec/operationId-starting-with-number/schema.json new file mode 100644 index 000000000..a1a772d40 --- /dev/null +++ b/tests/spec/operationId-starting-with-number/schema.json @@ -0,0 +1,78 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Test API with operationId starting with number", + "version": "1.0.0" + }, + "paths": { + "/users": { + "get": { + "operationId": "123getUser", + "summary": "Get user", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/posts": { + "post": { + "operationId": "456createPost", + "summary": "Create post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "title": { + "type": "string" + } + } + } + } + } + } + } + } + } + } +}