diff --git a/.changeset/fix-form-urlencoded-put.md b/.changeset/fix-form-urlencoded-put.md new file mode 100644 index 00000000..3cbcc143 --- /dev/null +++ b/.changeset/fix-form-urlencoded-put.md @@ -0,0 +1,8 @@ +--- +"swagger-typescript-api": patch +--- + +Fix: PUT requests with application/x-www-form-urlencoded content type + +This fixes an issue where PUT requests with `application/x-www-form-urlencoded` content type were incorrectly sent as `multipart/form-data`. +A new `createUrlEncoded` method has been added to the `HttpClient` to handle this content type correctly. diff --git a/src/schema-routes/schema-routes.ts b/src/schema-routes/schema-routes.ts index 2a0be6c5..f8afbcbf 100644 --- a/src/schema-routes/schema-routes.ts +++ b/src/schema-routes/schema-routes.ts @@ -561,21 +561,48 @@ export class SchemaRoutes { }); } - if (routeParams.formData.length) { - contentKind = CONTENT_KIND.FORM_DATA; + if ( + contentKind === CONTENT_KIND.URL_ENCODED && + routeParams.formData.length + ) { schema = this.convertRouteParamsIntoObject(routeParams.formData); content = this.schemaParserFabric.getInlineParseContent( schema, typeName, [operationId], ); - } else if (contentKind === CONTENT_KIND.FORM_DATA) { - schema = this.getSchemaFromRequestType(requestBody); + } else if (routeParams.formData.length) { + contentKind = CONTENT_KIND.FORM_DATA; + schema = this.convertRouteParamsIntoObject(routeParams.formData); content = this.schemaParserFabric.getInlineParseContent( schema, typeName, [operationId], ); + } else if (contentKind === CONTENT_KIND.URL_ENCODED) { + schema = this.getSchemaFromRequestType(requestBody); + content = this.schemaParserFabric.schemaUtils.safeAddNullToType( + requestBody, + this.getTypeFromRequestInfo({ + requestInfo: requestBody, + parsedSchemas, + operationId, + defaultType: "any", + typeName, + }), + ); + } else if (contentKind === CONTENT_KIND.FORM_DATA) { + schema = this.getSchemaFromRequestType(requestBody); + content = this.schemaParserFabric.schemaUtils.safeAddNullToType( + requestBody, + this.getTypeFromRequestInfo({ + requestInfo: requestBody, + parsedSchemas, + operationId, + defaultType: "any", + typeName, + }), + ); } else if (requestBody) { schema = this.getSchemaFromRequestType(requestBody); content = this.schemaParserFabric.schemaUtils.safeAddNullToType( @@ -1074,6 +1101,12 @@ export class SchemaRoutes { security: hasSecurity, method: method, requestParams: requestParamsSchema, + type: + requestBodyInfo.contentKind === CONTENT_KIND.FORM_DATA + ? "multipart/form-data" + : requestBodyInfo.contentKind === CONTENT_KIND.URL_ENCODED + ? "application/x-www-form-urlencoded" + : undefined, payload: specificArgs.body, query: specificArgs.query, diff --git a/templates/base/http-clients/axios-http-client.ejs b/templates/base/http-clients/axios-http-client.ejs index bc77f346..ccbfd8a3 100644 --- a/templates/base/http-clients/axios-http-client.ejs +++ b/templates/base/http-clients/axios-http-client.ejs @@ -99,6 +99,22 @@ export class HttpClient { }, new FormData()); } + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = (property instanceof Array) ? property : [property] + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + public request = async ({ secure, path, @@ -120,6 +136,10 @@ export class HttpClient { body = this.createFormData(body as Record); } + if (type === ContentType.UrlEncoded && body && body !== null && typeof body === "object") { + body = this.createUrlEncoded(body as Record); + } + if (type === ContentType.Text && body && body !== null && typeof body !== "string") { body = JSON.stringify(body); } diff --git a/tests/__snapshots__/extended.test.ts.snap b/tests/__snapshots__/extended.test.ts.snap index caf87072..ab60272b 100644 --- a/tests/__snapshots__/extended.test.ts.snap +++ b/tests/__snapshots__/extended.test.ts.snap @@ -6376,7 +6376,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), @@ -69452,7 +69452,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/__snapshots__/simple.test.ts.snap b/tests/__snapshots__/simple.test.ts.snap index 47478cb0..9c4995c2 100644 --- a/tests/__snapshots__/simple.test.ts.snap +++ b/tests/__snapshots__/simple.test.ts.snap @@ -3968,7 +3968,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), @@ -43623,7 +43623,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/axios/__snapshots__/basic.test.ts.snap b/tests/spec/axios/__snapshots__/basic.test.ts.snap index ba768ef9..be61e8ed 100644 --- a/tests/spec/axios/__snapshots__/basic.test.ts.snap +++ b/tests/spec/axios/__snapshots__/basic.test.ts.snap @@ -2037,6 +2037,23 @@ export class HttpClient { }, new FormData()); } + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + public request = async ({ secure, path, @@ -2063,6 +2080,15 @@ export class HttpClient { body = this.createFormData(body as Record); } + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + if ( type === ContentType.Text && body && diff --git a/tests/spec/axiosSingleHttpClient/__snapshots__/basic.test.ts.snap b/tests/spec/axiosSingleHttpClient/__snapshots__/basic.test.ts.snap index ca053dec..34166f2c 100644 --- a/tests/spec/axiosSingleHttpClient/__snapshots__/basic.test.ts.snap +++ b/tests/spec/axiosSingleHttpClient/__snapshots__/basic.test.ts.snap @@ -2037,6 +2037,23 @@ export class HttpClient { }, new FormData()); } + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + public request = async ({ secure, path, @@ -2063,6 +2080,15 @@ export class HttpClient { body = this.createFormData(body as Record); } + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + if ( type === ContentType.Text && body && diff --git a/tests/spec/content-type-fix/__snapshots__/basic.test.ts.snap b/tests/spec/content-type-fix/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..7de910c9 --- /dev/null +++ b/tests/spec/content-type-fix/__snapshots__/basic.test.ts.snap @@ -0,0 +1,273 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`content-type-fix > content-type-fix 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 ## + * --------------------------------------------------------------- + */ + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +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?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +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 instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return \`\${formItem}\`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Content Type Fix Test + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + testUrlencodedPut = { + /** + * No description + * + * @name TestUrlencodedPutUpdate + * @summary PUT with application/x-www-form-urlencoded + * @request PUT:/test-urlencoded-put + */ + testUrlencodedPutUpdate: ( + data: { + username: string; + age?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/test-urlencoded-put\`, + method: "PUT", + body: data, + type: ContentType.UrlEncoded, + ...params, + }), + }; + testFormdataPut = { + /** + * No description + * + * @name TestFormdataPutUpdate + * @summary PUT with multipart/form-data + * @request PUT:/test-formdata-put + */ + testFormdataPutUpdate: ( + data: { + /** @format binary */ + file: File; + metadata?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/test-formdata-put\`, + method: "PUT", + body: data, + type: ContentType.FormData, + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/content-type-fix/basic.test.ts b/tests/spec/content-type-fix/basic.test.ts new file mode 100644 index 00000000..3deff8e5 --- /dev/null +++ b/tests/spec/content-type-fix/basic.test.ts @@ -0,0 +1,37 @@ +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("content-type-fix", 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("content-type-fix", async () => { + await generateApi({ + fileName: "content-type-fix.ts", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "axios", + }); + + const content = await fs.readFile( + path.join(tmpdir, "content-type-fix.ts"), + { + encoding: "utf8", + }, + ); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/content-type-fix/schema.json b/tests/spec/content-type-fix/schema.json new file mode 100644 index 00000000..932aea9e --- /dev/null +++ b/tests/spec/content-type-fix/schema.json @@ -0,0 +1,66 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Content Type Fix Test", + "version": "1.0.0" + }, + "paths": { + "/test-urlencoded-put": { + "put": { + "summary": "PUT with application/x-www-form-urlencoded", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "type": "object", + "properties": { + "username": { + "type": "string" + }, + "age": { + "type": "integer" + } + }, + "required": ["username"] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + }, + "/test-formdata-put": { + "put": { + "summary": "PUT with multipart/form-data", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "metadata": { + "type": "string" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "Success" + } + } + } + } + } +} diff --git a/tests/spec/extractRequestBody/__snapshots__/basic.test.ts.snap b/tests/spec/extractRequestBody/__snapshots__/basic.test.ts.snap index 91bc048b..0be2c2f7 100644 --- a/tests/spec/extractRequestBody/__snapshots__/basic.test.ts.snap +++ b/tests/spec/extractRequestBody/__snapshots__/basic.test.ts.snap @@ -636,7 +636,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/extractResponseBody/__snapshots__/basic.test.ts.snap b/tests/spec/extractResponseBody/__snapshots__/basic.test.ts.snap index c2aa9c9c..37d103f8 100644 --- a/tests/spec/extractResponseBody/__snapshots__/basic.test.ts.snap +++ b/tests/spec/extractResponseBody/__snapshots__/basic.test.ts.snap @@ -637,7 +637,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/extractResponseError/__snapshots__/basic.test.ts.snap b/tests/spec/extractResponseError/__snapshots__/basic.test.ts.snap index 15e631b1..85571a6b 100644 --- a/tests/spec/extractResponseError/__snapshots__/basic.test.ts.snap +++ b/tests/spec/extractResponseError/__snapshots__/basic.test.ts.snap @@ -642,7 +642,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/form-urlencoded/__snapshots__/basic.test.ts.snap b/tests/spec/form-urlencoded/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..13f7001a --- /dev/null +++ b/tests/spec/form-urlencoded/__snapshots__/basic.test.ts.snap @@ -0,0 +1,249 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`form-urlencoded > form-urlencoded 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 ## + * --------------------------------------------------------------- + */ + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +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?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +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 instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return \`\${formItem}\`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Form URL Encoded Test + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + test = { + /** + * No description + * + * @name TestUpdate + * @summary A test endpoint for form-urlencoded + * @request PUT:/test + */ + testUpdate: ( + data: { + param1: string; + param2: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/test\`, + method: "PUT", + body: data, + type: ContentType.UrlEncoded, + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/form-urlencoded/basic.test.ts b/tests/spec/form-urlencoded/basic.test.ts new file mode 100644 index 00000000..bb115ed1 --- /dev/null +++ b/tests/spec/form-urlencoded/basic.test.ts @@ -0,0 +1,34 @@ +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("form-urlencoded", 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("form-urlencoded", async () => { + await generateApi({ + fileName: "form-urlencoded.ts", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "axios", + }); + + const content = await fs.readFile(path.join(tmpdir, "form-urlencoded.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/form-urlencoded/schema.json b/tests/spec/form-urlencoded/schema.json new file mode 100644 index 00000000..b74687b6 --- /dev/null +++ b/tests/spec/form-urlencoded/schema.json @@ -0,0 +1,34 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Form URL Encoded Test" + }, + "paths": { + "/test": { + "put": { + "summary": "A test endpoint for form-urlencoded", + "consumes": ["application/x-www-form-urlencoded"], + "parameters": [ + { + "name": "param1", + "in": "formData", + "type": "string", + "required": true + }, + { + "name": "param2", + "in": "formData", + "type": "integer", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + } + } + } + } + } +} diff --git a/tests/spec/js/__snapshots__/basic.test.ts.snap b/tests/spec/js/__snapshots__/basic.test.ts.snap index 019ed5c2..bea3b65c 100644 --- a/tests/spec/js/__snapshots__/basic.test.ts.snap +++ b/tests/spec/js/__snapshots__/basic.test.ts.snap @@ -78,6 +78,19 @@ export class HttpClient { return formData; }, new FormData()); } + createUrlEncoded(input) { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent = property instanceof Array ? property : [property]; + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + return searchParams; + }, new URLSearchParams()); + } request = async ({ secure, path, type, query, format, body, ...params }) => { const secureParams = ((typeof secure === "boolean" ? secure : this.secure) && @@ -94,6 +107,14 @@ export class HttpClient { ) { body = this.createFormData(body); } + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body); + } if ( type === ContentType.Text && body && diff --git a/tests/spec/moduleNameFirstTag/__snapshots__/basic.test.ts.snap b/tests/spec/moduleNameFirstTag/__snapshots__/basic.test.ts.snap index f201e51e..cef0f9e2 100644 --- a/tests/spec/moduleNameFirstTag/__snapshots__/basic.test.ts.snap +++ b/tests/spec/moduleNameFirstTag/__snapshots__/basic.test.ts.snap @@ -533,7 +533,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/moduleNameIndex/__snapshots__/basic.test.ts.snap b/tests/spec/moduleNameIndex/__snapshots__/basic.test.ts.snap index 201f1a46..f0a195a7 100644 --- a/tests/spec/moduleNameIndex/__snapshots__/basic.test.ts.snap +++ b/tests/spec/moduleNameIndex/__snapshots__/basic.test.ts.snap @@ -533,7 +533,7 @@ export class Api< method: "POST", body: data, secure: true, - type: ContentType.FormData, + type: ContentType.UrlEncoded, ...params, }), diff --git a/tests/spec/multipart-form-data/__snapshots__/basic.test.ts.snap b/tests/spec/multipart-form-data/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..21633259 --- /dev/null +++ b/tests/spec/multipart-form-data/__snapshots__/basic.test.ts.snap @@ -0,0 +1,250 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`multipart-form-data > multipart-form-data 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 ## + * --------------------------------------------------------------- + */ + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +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?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +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 instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return \`\${formItem}\`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Multipart Form Data Test API + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + upload = { + /** + * No description + * + * @name UploadUpdate + * @summary Upload a file + * @request PUT:/upload + */ + uploadUpdate: ( + data: { + /** @format binary */ + file: File; + description?: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/upload\`, + method: "PUT", + body: data, + type: ContentType.FormData, + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/multipart-form-data/basic.test.ts b/tests/spec/multipart-form-data/basic.test.ts new file mode 100644 index 00000000..d50fdf73 --- /dev/null +++ b/tests/spec/multipart-form-data/basic.test.ts @@ -0,0 +1,37 @@ +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("multipart-form-data", 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("multipart-form-data", async () => { + await generateApi({ + fileName: "multipart-form-data.ts", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "axios", + }); + + const content = await fs.readFile( + path.join(tmpdir, "multipart-form-data.ts"), + { + encoding: "utf8", + }, + ); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/multipart-form-data/schema.json b/tests/spec/multipart-form-data/schema.json new file mode 100644 index 00000000..c615eb82 --- /dev/null +++ b/tests/spec/multipart-form-data/schema.json @@ -0,0 +1,38 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Multipart Form Data Test API", + "version": "1.0.0" + }, + "paths": { + "/upload": { + "put": { + "summary": "Upload a file", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "description": { + "type": "string" + } + }, + "required": ["file"] + } + } + } + }, + "responses": { + "200": { + "description": "File uploaded successfully" + } + } + } + } + } +} diff --git a/tests/spec/plan-update/__snapshots__/basic.test.ts.snap b/tests/spec/plan-update/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..3dbca2ad --- /dev/null +++ b/tests/spec/plan-update/__snapshots__/basic.test.ts.snap @@ -0,0 +1,253 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`plan-update > plan-update 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 ## + * --------------------------------------------------------------- + */ + +import type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + HeadersDefaults, + ResponseType, +} from "axios"; +import axios from "axios"; + +export type QueryParamsType = Record; + +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?: ResponseType; + /** request body */ + body?: unknown; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig + extends Omit { + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | AxiosRequestConfig | void; + secure?: boolean; + format?: ResponseType; +} + +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 instance: AxiosInstance; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private secure?: boolean; + private format?: ResponseType; + + constructor({ + securityWorker, + secure, + format, + ...axiosConfig + }: ApiConfig = {}) { + this.instance = axios.create({ + ...axiosConfig, + baseURL: axiosConfig.baseURL || "", + }); + this.secure = secure; + this.format = format; + this.securityWorker = securityWorker; + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected mergeRequestParams( + params1: AxiosRequestConfig, + params2?: AxiosRequestConfig, + ): AxiosRequestConfig { + const method = params1.method || (params2 && params2.method); + + return { + ...this.instance.defaults, + ...params1, + ...(params2 || {}), + headers: { + ...((method && + this.instance.defaults.headers[ + method.toLowerCase() as keyof HeadersDefaults + ]) || + {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected stringifyFormItem(formItem: unknown) { + if (typeof formItem === "object" && formItem !== null) { + return JSON.stringify(formItem); + } else { + return \`\${formItem}\`; + } + } + + protected createFormData(input: Record): FormData { + if (input instanceof FormData) { + return input; + } + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + const isFileType = formItem instanceof Blob || formItem instanceof File; + formData.append( + key, + isFileType ? formItem : this.stringifyFormItem(formItem), + ); + } + + return formData; + }, new FormData()); + } + + protected createUrlEncoded(input: Record): URLSearchParams { + if (input instanceof URLSearchParams) { + return input; + } + return Object.keys(input || {}).reduce((searchParams, key) => { + const property = input[key]; + const propertyContent: any[] = + property instanceof Array ? property : [property]; + + for (const formItem of propertyContent) { + searchParams.append(key, this.stringifyFormItem(formItem)); + } + + return searchParams; + }, new URLSearchParams()); + } + + public request = async ({ + secure, + path, + type, + query, + format, + body, + ...params + }: FullRequestParams): Promise> => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const responseFormat = format || this.format || undefined; + + if ( + type === ContentType.FormData && + body && + body !== null && + typeof body === "object" + ) { + body = this.createFormData(body as Record); + } + + if ( + type === ContentType.UrlEncoded && + body && + body !== null && + typeof body === "object" + ) { + body = this.createUrlEncoded(body as Record); + } + + if ( + type === ContentType.Text && + body && + body !== null && + typeof body !== "string" + ) { + body = JSON.stringify(body); + } + + return this.instance.request({ + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type ? { "Content-Type": type } : {}), + }, + params: query, + responseType: responseFormat, + data: body, + url: path, + }); + }; +} + +/** + * @title Plan Update Test + * @version 1.0.0 + */ +export class Api< + SecurityDataType extends unknown, +> extends HttpClient { + plans = { + /** + * @description Updates the name of a plan. Requires member role for the plan's owner organization. + * + * @tags plans + * @name PlansUpdate + * @summary Update plan name + * @request PUT:/plans/{plan_id} + * @secure + */ + plansUpdate: ( + planId: string, + data: { + /** New plan name */ + name: string; + }, + params: RequestParams = {}, + ) => + this.request({ + path: \`/plans/\${planId}\`, + method: "PUT", + body: data, + secure: true, + type: ContentType.UrlEncoded, + ...params, + }), + }; +} +" +`; diff --git a/tests/spec/plan-update/basic.test.ts b/tests/spec/plan-update/basic.test.ts new file mode 100644 index 00000000..7dfacbb7 --- /dev/null +++ b/tests/spec/plan-update/basic.test.ts @@ -0,0 +1,34 @@ +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("plan-update", 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("plan-update", async () => { + await generateApi({ + fileName: "plan-update.ts", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + generateClient: true, + httpClientType: "axios", + }); + + const content = await fs.readFile(path.join(tmpdir, "plan-update.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/plan-update/schema.json b/tests/spec/plan-update/schema.json new file mode 100644 index 00000000..302fa8b3 --- /dev/null +++ b/tests/spec/plan-update/schema.json @@ -0,0 +1,42 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Plan Update Test" + }, + "paths": { + "/plans/{plan_id}": { + "put": { + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "description": "Updates the name of a plan. Requires member role for the plan's owner organization.", + "consumes": ["application/x-www-form-urlencoded"], + "produces": ["application/json"], + "tags": ["plans"], + "summary": "Update plan name", + "parameters": [ + { + "type": "string", + "description": "Plan ID", + "name": "plan_id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "New plan name", + "name": "name", + "in": "formData", + "required": true + } + ] + } + } + } +}