diff --git a/.changeset/late-bottles-reply.md b/.changeset/late-bottles-reply.md new file mode 100644 index 000000000..ad5c45b07 --- /dev/null +++ b/.changeset/late-bottles-reply.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": patch +--- + +Escape `*/` sequences in generated JSDoc content to prevent comment injection from OpenAPI fields. diff --git a/src/code-gen-process.ts b/src/code-gen-process.ts index d82296753..61f00bbdf 100644 --- a/src/code-gen-process.ts +++ b/src/code-gen-process.ts @@ -235,6 +235,8 @@ export class CodeGenProcess { Ts: this.config.Ts, formatDescription: this.schemaParserFabric.schemaFormatters.formatDescription, + escapeJSDocContent: + this.schemaParserFabric.schemaFormatters.escapeJSDocContent, internalCase: camelCase, classNameCase: pascalCase, pascalCase: pascalCase, diff --git a/src/schema-parser/schema-formatters.ts b/src/schema-parser/schema-formatters.ts index e655e1468..0f9461c20 100644 --- a/src/schema-parser/schema-formatters.ts +++ b/src/schema-parser/schema-formatters.ts @@ -30,10 +30,17 @@ export class SchemaFormatters { }; } + const escapedContent = parsedSchema.content.map((item) => ({ + ...item, + description: item.description + ? this.escapeJSDocContent(item.description) + : "", + })); + return { ...parsedSchema, $content: parsedSchema.content, - content: this.config.Ts.EnumFieldsWrapper(parsedSchema.content), + content: this.config.Ts.EnumFieldsWrapper(escapedContent), }; }, [SCHEMA_TYPES.OBJECT]: (parsedSchema) => { @@ -103,20 +110,34 @@ export class SchemaFormatters { return formatterFn?.(parsedSchema) || parsedSchema; }; - formatDescription = (description: string | undefined, inline?: boolean) => { + // OpenAPI fields are untrusted input that may contain `*/` which would + // prematurely close JSDoc block comments in generated TypeScript output. + // Note: only `undefined` maps to empty string; `null` is preserved as "null" + // because `@default null` is a valid JSDoc annotation for nullable fields. + escapeJSDocContent = (content: unknown): string => { + if (content === undefined) return ""; + const str = typeof content === "string" ? content : String(content); + return str.replace(/\*\//g, "*\\/"); + }; + + formatDescription = ( + description: string | undefined, + inline?: boolean, + ): string => { if (!description) return ""; - const hasMultipleLines = description.includes("\n"); + const escapedDescription = this.escapeJSDocContent(description); + const hasMultipleLines = escapedDescription.includes("\n"); - if (!hasMultipleLines) return description; + if (!hasMultipleLines) return escapedDescription; if (inline) { - return compact(description.split(/\n/g).map((part) => part.trim())).join( - " ", - ); + return compact( + escapedDescription.split(/\n/g).map((part) => part.trim()), + ).join(" "); } - return description.replace(/\n$/g, ""); + return escapedDescription.replace(/\n$/g, ""); }; formatObjectContent = (content) => { diff --git a/templates/base/data-contract-jsdoc.ejs b/templates/base/data-contract-jsdoc.ejs index 6dcfa9158..d2e3e211f 100644 --- a/templates/base/data-contract-jsdoc.ejs +++ b/templates/base/data-contract-jsdoc.ejs @@ -1,27 +1,27 @@ <% const { data, utils } = it; -const { formatDescription, require, _ } = utils; +const { formatDescription, escapeJSDocContent, require, _ } = utils; const stringify = (value) => _.isObject(value) ? JSON.stringify(value) : _.isString(value) ? `"${value}"` : value; const jsDocLines = _.compact([ - data.title, + data.title && formatDescription(data.title), data.description && formatDescription(data.description), !_.isUndefined(data.deprecated) && data.deprecated && '@deprecated', - !_.isUndefined(data.format) && `@format ${data.format}`, - !_.isUndefined(data.minimum) && `@min ${data.minimum}`, - !_.isUndefined(data.multipleOf) && `@multipleOf ${data.multipleOf}`, - !_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${data.exclusiveMinimum}`, - !_.isUndefined(data.maximum) && `@max ${data.maximum}`, - !_.isUndefined(data.minLength) && `@minLength ${data.minLength}`, - !_.isUndefined(data.maxLength) && `@maxLength ${data.maxLength}`, - !_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${data.exclusiveMaximum}`, - !_.isUndefined(data.maxItems) && `@maxItems ${data.maxItems}`, - !_.isUndefined(data.minItems) && `@minItems ${data.minItems}`, - !_.isUndefined(data.uniqueItems) && `@uniqueItems ${data.uniqueItems}`, - !_.isUndefined(data.default) && `@default ${stringify(data.default)}`, - !_.isUndefined(data.pattern) && `@pattern ${data.pattern}`, - !_.isUndefined(data.example) && `@example ${stringify(data.example)}` + !_.isUndefined(data.format) && `@format ${escapeJSDocContent(data.format)}`, + !_.isUndefined(data.minimum) && `@min ${escapeJSDocContent(data.minimum)}`, + !_.isUndefined(data.multipleOf) && `@multipleOf ${escapeJSDocContent(data.multipleOf)}`, + !_.isUndefined(data.exclusiveMinimum) && `@exclusiveMin ${escapeJSDocContent(data.exclusiveMinimum)}`, + !_.isUndefined(data.maximum) && `@max ${escapeJSDocContent(data.maximum)}`, + !_.isUndefined(data.minLength) && `@minLength ${escapeJSDocContent(data.minLength)}`, + !_.isUndefined(data.maxLength) && `@maxLength ${escapeJSDocContent(data.maxLength)}`, + !_.isUndefined(data.exclusiveMaximum) && `@exclusiveMax ${escapeJSDocContent(data.exclusiveMaximum)}`, + !_.isUndefined(data.maxItems) && `@maxItems ${escapeJSDocContent(data.maxItems)}`, + !_.isUndefined(data.minItems) && `@minItems ${escapeJSDocContent(data.minItems)}`, + !_.isUndefined(data.uniqueItems) && `@uniqueItems ${escapeJSDocContent(data.uniqueItems)}`, + !_.isUndefined(data.default) && `@default ${escapeJSDocContent(stringify(data.default))}`, + !_.isUndefined(data.pattern) && `@pattern ${escapeJSDocContent(data.pattern)}`, + !_.isUndefined(data.example) && `@example ${escapeJSDocContent(stringify(data.example))}` ]).join('\n').split('\n'); %> <% if (jsDocLines.every(_.isEmpty)) { %> diff --git a/templates/base/object-field-jsdoc.ejs b/templates/base/object-field-jsdoc.ejs index ec39314bc..a693e44ef 100644 --- a/templates/base/object-field-jsdoc.ejs +++ b/templates/base/object-field-jsdoc.ejs @@ -1,18 +1,18 @@ <% const { field, utils } = it; -const { formatDescription, require, _ } = utils; +const { formatDescription, escapeJSDocContent, require, _ } = utils; const comments = _.uniq( _.compact([ - field.title, - field.description, + field.title && formatDescription(field.title), + field.description && formatDescription(field.description), field.deprecated && ` * @deprecated`, - !_.isUndefined(field.format) && `@format ${field.format}`, - !_.isUndefined(field.minimum) && `@min ${field.minimum}`, - !_.isUndefined(field.maximum) && `@max ${field.maximum}`, - !_.isUndefined(field.pattern) && `@pattern ${field.pattern}`, + !_.isUndefined(field.format) && `@format ${escapeJSDocContent(field.format)}`, + !_.isUndefined(field.minimum) && `@min ${escapeJSDocContent(field.minimum)}`, + !_.isUndefined(field.maximum) && `@max ${escapeJSDocContent(field.maximum)}`, + !_.isUndefined(field.pattern) && `@pattern ${escapeJSDocContent(field.pattern)}`, !_.isUndefined(field.example) && - `@example ${_.isObject(field.example) ? JSON.stringify(field.example) : field.example}`, + `@example ${escapeJSDocContent(_.isObject(field.example) ? JSON.stringify(field.example) : field.example)}`, ]).reduce((acc, comment) => [...acc, ...comment.split(/\n/g)], []), ); %> diff --git a/templates/base/route-docs.ejs b/templates/base/route-docs.ejs index 3de625adf..42b99b59b 100644 --- a/templates/base/route-docs.ejs +++ b/templates/base/route-docs.ejs @@ -1,24 +1,24 @@ <% const { config, route, utils } = it; -const { _, formatDescription, fmtToJSDocLine, pascalCase, require } = utils; +const { _, formatDescription, escapeJSDocContent, fmtToJSDocLine, pascalCase, require } = utils; const { raw, request, routeName } = route; const jsDocDescription = raw.description ? ` * @description ${formatDescription(raw.description, true)}` : fmtToJSDocLine('No description', { eol: false }); const jsDocLines = _.compact([ - _.size(raw.tags) && ` * @tags ${raw.tags.join(", ")}`, - ` * @name ${pascalCase(routeName.usage)}`, - raw.summary && ` * @summary ${raw.summary}`, - ` * @request ${_.upperCase(request.method)}:${raw.route}`, + _.size(raw.tags) && ` * @tags ${raw.tags.map((tag) => formatDescription(tag, true)).join(", ")}`, + ` * @name ${escapeJSDocContent(pascalCase(routeName.usage))}`, + raw.summary && ` * @summary ${formatDescription(raw.summary, true)}`, + ` * @request ${escapeJSDocContent(_.upperCase(request.method))}:${escapeJSDocContent(raw.route)}`, raw.deprecated && ` * @deprecated`, - routeName.duplicate && ` * @originalName ${routeName.original}`, + routeName.duplicate && ` * @originalName ${escapeJSDocContent(routeName.original)}`, routeName.duplicate && ` * @duplicate`, request.security && ` * @secure`, ...(config.generateResponses && raw.responsesTypes.length ? raw.responsesTypes.map( ({ type, status, description, isSuccess }) => - ` * @response \`${status}\` \`${_.replace(_.replace(type, /\/\*/g, "\\*"), /\*\//g, "*\\")}\` ${description}`, + ` * @response \`${escapeJSDocContent(status)}\` \`${escapeJSDocContent(type)}\` ${formatDescription(description, true)}`, ) : []), ]).map(str => str.trimEnd()).join("\n"); diff --git a/templates/default/api.ejs b/templates/default/api.ejs index a393c917f..8684a18ec 100644 --- a/templates/default/api.ejs +++ b/templates/default/api.ejs @@ -1,24 +1,24 @@ <% const { apiConfig, routes, utils, config } = it; const { info, servers, externalDocs } = apiConfig; -const { _, require, formatDescription } = utils; +const { _, require, formatDescription, escapeJSDocContent } = utils; const server = (servers && servers[0]) || { url: "" }; const descriptionLines = _.compact([ - `@title ${info.title || "No title"}`, - info.version && `@version ${info.version}`, + `@title ${escapeJSDocContent(info.title || "No title")}`, + info.version && `@version ${escapeJSDocContent(info.version)}`, info.license && `@license ${_.compact([ - info.license.name, - info.license.url && `(${info.license.url})`, + info.license.name && escapeJSDocContent(info.license.name), + info.license.url && `(${escapeJSDocContent(info.license.url)})`, ]).join(" ")}`, - info.termsOfService && `@termsOfService ${info.termsOfService}`, - server.url && `@baseUrl ${server.url}`, - externalDocs.url && `@externalDocs ${externalDocs.url}`, + info.termsOfService && `@termsOfService ${escapeJSDocContent(info.termsOfService)}`, + server.url && `@baseUrl ${escapeJSDocContent(server.url)}`, + externalDocs.url && `@externalDocs ${escapeJSDocContent(externalDocs.url)}`, info.contact && `@contact ${_.compact([ - info.contact.name, - info.contact.email && `<${info.contact.email}>`, - info.contact.url && `(${info.contact.url})`, + info.contact.name && escapeJSDocContent(info.contact.name), + info.contact.email && `<${escapeJSDocContent(info.contact.email)}>`, + info.contact.url && `(${escapeJSDocContent(info.contact.url)})`, ]).join(" ")}`, info.description && " ", info.description && _.replace(formatDescription(info.description), /\n/g, "\n * "), diff --git a/tests/spec/issue-1321/__snapshots__/basic.test.ts.snap b/tests/spec/issue-1321/__snapshots__/basic.test.ts.snap new file mode 100644 index 000000000..a5629bd9c --- /dev/null +++ b/tests/spec/issue-1321/__snapshots__/basic.test.ts.snap @@ -0,0 +1,676 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`issue-1321 > escapes jsdoc closing markers 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 ## + * --------------------------------------------------------------- + */ + +/** Status *\\/ enum description */ +export enum Status { + /** Active *\\/ status */ + ActiveOn = "active*/on", + /** Inactive *\\/ status */ + InactiveOff = "inactive*/off", +} + +/** + * Item *\\/ title + * Item *\\/ description + * second line *\\/ end + */ +export interface Item { + /** + * name *\\/ description + * @pattern ^[a-z]*\\/suffix$ + * @example "value *\\/ sample" + */ + name?: string; + /** + * payload *\\/ description + * @example {"message":"object *\\/ example","url":"https://example.com/*\\/path"} + */ + payload?: object; + /** + * formatted *\\/ description + * @format custom*\\/fmt + * @default "default*\\/value" + */ + formatted?: 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< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +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 = "https://api.example.com/*/v1"; + 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) => { + if (input instanceof FormData) { + return input; + } + + return 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 as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[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 Issue 1321 *\\/ title + * @version 1.0.0 *\\/ version + * @license MIT *\\/ license (https://license.example.com/*\\/mit) + * @termsOfService https://example.com/terms*\\/v1 + * @baseUrl https://api.example.com/*\\/v1 + * @externalDocs https://docs.example.com/reference*\\/openapi + * @contact contact *\\/ name (https://contact.example.com/*\\/profile) + * + * API description *\\/ close marker + * with multiline *\\/ line + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + information = { + /** + * @description description *\\/ break + * + * @tags tag*\\/one, tag/*two + * @name FeedList + * @summary summary *\\/ break + * @request GET:/information*\\/feed + */ + feedList: (params: RequestParams = {}) => + this.request({ + path: \`/information*/feed\`, + method: "GET", + format: "json", + ...params, + }), + }; +} +" +`; + +exports[`issue-1321 > escapes jsdoc closing markers with generateResponses 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 ## + * --------------------------------------------------------------- + */ + +/** Status *\\/ enum description */ +export enum Status { + /** Active *\\/ status */ + ActiveOn = "active*/on", + /** Inactive *\\/ status */ + InactiveOff = "inactive*/off", +} + +/** + * Item *\\/ title + * Item *\\/ description + * second line *\\/ end + */ +export interface Item { + /** + * name *\\/ description + * @pattern ^[a-z]*\\/suffix$ + * @example "value *\\/ sample" + */ + name?: string; + /** + * payload *\\/ description + * @example {"message":"object *\\/ example","url":"https://example.com/*\\/path"} + */ + payload?: object; + /** + * formatted *\\/ description + * @format custom*\\/fmt + * @default "default*\\/value" + */ + formatted?: 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< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +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 = "https://api.example.com/*/v1"; + 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) => { + if (input instanceof FormData) { + return input; + } + + return 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 as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[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 Issue 1321 *\\/ title + * @version 1.0.0 *\\/ version + * @license MIT *\\/ license (https://license.example.com/*\\/mit) + * @termsOfService https://example.com/terms*\\/v1 + * @baseUrl https://api.example.com/*\\/v1 + * @externalDocs https://docs.example.com/reference*\\/openapi + * @contact contact *\\/ name (https://contact.example.com/*\\/profile) + * + * API description *\\/ close marker + * with multiline *\\/ line + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + information = { + /** + * @description description *\\/ break + * + * @tags tag*\\/one, tag/*two + * @name FeedList + * @summary summary *\\/ break + * @request GET:/information*\\/feed + * @response \`200\` \`Item\` response *\\/ close + */ + feedList: (params: RequestParams = {}) => + this.request({ + path: \`/information*/feed\`, + method: "GET", + format: "json", + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/issue-1321/basic.test.ts b/tests/spec/issue-1321/basic.test.ts new file mode 100644 index 000000000..fef43f1d3 --- /dev/null +++ b/tests/spec/issue-1321/basic.test.ts @@ -0,0 +1,95 @@ +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"; +import { SchemaFormatters } from "../../../src/schema-parser/schema-formatters.js"; + +describe("escapeJSDocContent", () => { + const formatters = new SchemaFormatters( + {} as ConstructorParameters[0], + ); + const escapeJSDoc = formatters.escapeJSDocContent; + + test("returns empty string for undefined", () => { + expect(escapeJSDoc(undefined)).toBe(""); + }); + + test("returns string unchanged when no */ present", () => { + expect(escapeJSDoc("clean string")).toBe("clean string"); + }); + + test("escapes a single */ sequence", () => { + expect(escapeJSDoc("has */ inside")).toBe("has *\\/ inside"); + }); + + test("escapes multiple */ sequences", () => { + expect(escapeJSDoc("a */ b */ c")).toBe("a *\\/ b *\\/ c"); + }); + + test("coerces number to string", () => { + expect(escapeJSDoc(42)).toBe("42"); + }); + + test("coerces boolean to string", () => { + expect(escapeJSDoc(true)).toBe("true"); + }); + + test("coerces null to string", () => { + expect(escapeJSDoc(null)).toBe("null"); + }); + + test("returns empty string for empty string input", () => { + expect(escapeJSDoc("")).toBe(""); + }); + + test("does not escape /* (opening comment)", () => { + expect(escapeJSDoc("/* open")).toBe("/* open"); + }); +}); + +describe("issue-1321", 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("escapes jsdoc closing markers", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); + + test("escapes jsdoc closing markers with generateResponses", async () => { + await generateApi({ + fileName: "schema-responses", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateResponses: true, + }); + + const content = await fs.readFile( + path.join(tmpdir, "schema-responses.ts"), + { + encoding: "utf8", + }, + ); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/issue-1321/schema.json b/tests/spec/issue-1321/schema.json new file mode 100644 index 000000000..d5d1476fe --- /dev/null +++ b/tests/spec/issue-1321/schema.json @@ -0,0 +1,84 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Issue 1321 */ title", + "version": "1.0.0 */ version", + "description": "API description */ close marker\nwith multiline */ line", + "termsOfService": "https://example.com/terms*/v1", + "contact": { + "name": "contact */ name", + "email": "security*/team@example.com", + "url": "https://contact.example.com/*/profile" + }, + "license": { + "name": "MIT */ license", + "url": "https://license.example.com/*/mit" + } + }, + "externalDocs": { + "url": "https://docs.example.com/reference*/openapi" + }, + "servers": [ + { + "url": "https://api.example.com/*/v1" + } + ], + "paths": { + "/information*/feed": { + "get": { + "tags": ["tag*/one", "tag/*two"], + "summary": "summary */ break", + "description": "description */ break", + "responses": { + "200": { + "description": "response */ close", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Item": { + "title": "Item */ title", + "description": "Item */ description\nsecond line */ end", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "name */ description", + "pattern": "^[a-z]*/suffix$", + "example": "value */ sample" + }, + "payload": { + "type": "object", + "description": "payload */ description", + "example": { + "message": "object */ example", + "url": "https://example.com/*/path" + } + }, + "formatted": { + "type": "string", + "description": "formatted */ description", + "format": "custom*/fmt", + "default": "default*/value" + } + } + }, + "Status": { + "type": "string", + "description": "Status */ enum description", + "enum": ["active*/on", "inactive*/off"], + "x-enum-descriptions": ["Active */ status", "Inactive */ status"] + } + } + } +} diff --git a/tests/spec/responses/__snapshots__/basic.test.ts.snap b/tests/spec/responses/__snapshots__/basic.test.ts.snap index c368fa923..f8eb1386c 100644 --- a/tests/spec/responses/__snapshots__/basic.test.ts.snap +++ b/tests/spec/responses/__snapshots__/basic.test.ts.snap @@ -329,7 +329,7 @@ export class Api< * @name KeyRevokeNosecret * @request DELETE:/key * @response \`200\` \`{ - \\** pending or done *\\ + /** pending or done *\\/ status?: string, }\` Successfully deleted @@ -370,9 +370,9 @@ export class Api< * @name KeyRegister * @request POST:/key * @response \`201\` \`{ - \\** revoke key *\\ + /** revoke key *\\/ secret?: string, - \\** registered *\\ + /** registered *\\/ status?: string, }\` Successfully registered @@ -403,7 +403,7 @@ export class Api< * @name KeyRevoke * @request DELETE:/key/{PK} * @response \`200\` \`{ - \\** done *\\ + /** done *\\/ status?: string, }\` Successful response @@ -440,10 +440,10 @@ export class Api< * @name GetKey * @request GET:/key/{PK} * @response \`200\` \`{ - \\** @format date-time *\\ + /** @format date-time *\\/ since?: string, status?: string, - \\** base64safe encoded public signing key *\\ + /** base64safe encoded public signing key *\\/ sub?: string, }\` Successfully retrieved @@ -493,7 +493,7 @@ export class Api< * @name KeyUpdate * @request POST:/key/{PK} * @response \`200\` \`{ - \\** confirmed *\\ + /** confirmed *\\/ status?: string, }\` Successfully updated @@ -522,7 +522,7 @@ export class Api< * @name KeyBind * @request PUT:/key/{PK} * @response \`200\` \`{ - \\** confirmed *\\ + /** confirmed *\\/ status?: string, }\` Successfully updated @@ -553,7 +553,7 @@ export class Api< * @name PushLoginRequest * @request POST:/login * @response \`200\` \`{ - \\** sent *\\ + /** sent *\\/ status?: string, }\` Successful response @@ -591,9 +591,9 @@ export class Api< * @name SignRequest * @request POST:/scope * @response \`201\` \`{ - \\** 20-character ID *\\ + /** 20-character ID *\\/ job?: string, - \\** waiting *\\ + /** waiting *\\/ status?: string, }\` Successful response @@ -632,7 +632,7 @@ export class Api< * @name SignDelete * @request DELETE:/scope/{job} * @response \`200\` \`{ - \\** done *\\ + /** done *\\/ status?: string, }\` Successfully deleted @@ -662,7 +662,7 @@ export class Api< * @response \`200\` \`{ exp?: number, field?: string, - \\** base64safe encoded public signing key *\\ + /** base64safe encoded public signing key *\\/ sub?: string, }\` Successful response (JWT) @@ -711,7 +711,7 @@ export class Api< * @name SignConfirm * @request POST:/scope/{job} * @response \`202\` \`{ - \\** confirmed *\\ + /** confirmed *\\/ status?: string, }\` Successfully confirmed @@ -742,9 +742,9 @@ export class Api< * @name SignUpdate * @request PUT:/scope/{job} * @response \`200\` \`{ - \\** result is JWT or JSON?? *\\ + /** result is JWT or JSON?? *\\/ jwt?: string, - \\** ready *\\ + /** ready *\\/ status?: string, }\` Successfully updated