Skip to content

Commit 10efc46

Browse files
authored
fix: set headers for file and JSON data parts in multipart from request (#304)
1 parent 11d1de3 commit 10efc46

19 files changed

Lines changed: 498 additions & 25 deletions

packages/authentication-adapters/test/accessTokenAdapter.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
1+
import { callHttpInterceptors } from '@apimatic/core';
22
import { accessTokenAuthenticationProvider } from '../src/accessTokenAdapter';
3-
import { HttpRequest } from '../../core-interfaces/src/httpRequest';
4-
import { HttpResponse } from '../../core-interfaces/src/httpResponse';
3+
import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
54

65
describe('test access token authentication scheme', () => {
76
const config = {

packages/authentication-adapters/test/basicAuthenticationAdapter.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
1+
import { callHttpInterceptors } from '@apimatic/core';
22
import { basicAuthenticationProvider } from '../src/basicAuthenticationAdapter';
3-
import { HttpRequest } from '../../core-interfaces/src/httpRequest';
4-
import { HttpResponse } from '../../core-interfaces/src/httpResponse';
3+
import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
54

65
describe('test basic authentication scheme', () => {
76
const config = {

packages/authentication-adapters/test/customHeaderAuthenticationAdapter.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
1+
import { callHttpInterceptors } from '@apimatic/core';
22
import { customHeaderAuthenticationProvider } from '../src/customHeaderAuthenticationAdapter';
3-
import { HttpRequest } from '../../core-interfaces/src/httpRequest';
4-
import { HttpResponse } from '../../core-interfaces/src/httpResponse';
3+
import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
54

65
describe('test custom header authentication scheme', () => {
76
test.each([

packages/authentication-adapters/test/customQueryAuthenticationAdapter.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
1+
import { callHttpInterceptors } from '@apimatic/core';
22
import { customQueryAuthenticationProvider } from '../src/customQueryAuthenticationAdapter';
3-
import { HttpRequest } from '../../core-interfaces/src/httpRequest';
4-
import { HttpResponse } from '../../core-interfaces/src/httpResponse';
3+
import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
54

65
describe('test custom query authentication scheme', () => {
76
test.each([

packages/authentication-adapters/test/noAuthenticationAdapter.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { callHttpInterceptors } from '../../core/src/http/httpInterceptor';
1+
import { callHttpInterceptors } from '@apimatic/core';
22
import { noneAuthenticationProvider } from '../src/noAuthenticationAdapter';
3-
import { HttpRequest } from '../../core-interfaces/src/httpRequest';
4-
import { HttpResponse } from '../../core-interfaces/src/httpResponse';
3+
import { HttpRequest, HttpResponse } from '@apimatic/core-interfaces';
54

65
describe('test access token authentication scheme', () => {
76
const response: HttpResponse = {

packages/axios-client-adapter/src/httpClient.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ import FormData from 'form-data';
1212
import {
1313
CONTENT_TYPE_HEADER,
1414
FORM_URLENCODED_CONTENT_TYPE,
15+
lookupCaseInsensitive,
1516
} from '@apimatic/http-headers';
1617
import {
1718
HttpRequest,
1819
HttpResponse,
20+
isFormDataWrapper,
1921
RetryConfiguration,
2022
} from '@apimatic/core-interfaces';
2123
import { urlEncodeKeyValuePairs } from '@apimatic/http-query';
@@ -107,7 +109,16 @@ export class HttpClient {
107109
});
108110
}
109111

110-
form.append(iter.key, fileData, iter.value.options);
112+
form.append(iter.key, fileData, {
113+
...createFormDataOptions(iter.value.options?.headers || {}),
114+
filename: iter.value.options?.filename,
115+
});
116+
} else if (isFormDataWrapper(iter.value)) {
117+
form.append(
118+
iter.key,
119+
iter.value.data,
120+
createFormDataOptions(iter.value.headers || {})
121+
);
111122
} else {
112123
form.append(iter.key, iter.value);
113124
}
@@ -150,7 +161,6 @@ export class HttpClient {
150161
newRequest.headers = headers;
151162

152163
this.setProxyAgent(newRequest);
153-
154164
return newRequest;
155165
}
156166

@@ -276,3 +286,21 @@ export function isBlob(value: unknown): value is Blob {
276286
Object.prototype.toString.call(value) === '[object Blob]'
277287
);
278288
}
289+
290+
export function createFormDataOptions(
291+
headers: Record<string, string>
292+
): FormData.AppendOptions {
293+
const headerKey = lookupCaseInsensitive(headers, 'content-type');
294+
if (!headerKey) {
295+
return {
296+
header: headers,
297+
};
298+
}
299+
300+
const contentType = headers[headerKey];
301+
delete headers[headerKey];
302+
return {
303+
contentType,
304+
header: headers,
305+
};
306+
}

packages/axios-client-adapter/test/httpClient.test.ts

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { DEFAULT_TIMEOUT, HttpClient, isBlob } from '../src/httpClient';
1+
import {
2+
DEFAULT_TIMEOUT,
3+
HttpClient,
4+
isBlob,
5+
createFormDataOptions,
6+
} from '../src/httpClient';
27
import {
38
AxiosHeaders,
49
AxiosRequestConfig,
@@ -14,6 +19,7 @@ import {
1419
HttpResponse,
1520
} from '@apimatic/core-interfaces';
1621
import { FileWrapper } from '@apimatic/file-wrapper';
22+
import { createFormData } from '@apimatic/core-interfaces';
1723
import FormData from 'form-data';
1824
import fs from 'fs';
1925

@@ -138,6 +144,102 @@ describe('HTTP Client', () => {
138144
});
139145
});
140146

147+
it('converts request with http form-data containing FormDataWrapper and FileWrapper', async () => {
148+
const httpClient = new HttpClient(AbortError);
149+
// Need FileWrapper to trigger multipart mode
150+
const fileWrapper = new FileWrapper(
151+
fs.createReadStream('test/dummy_file.txt'),
152+
{
153+
filename: 'document.txt',
154+
headers: { 'content-type': 'text/plain' },
155+
}
156+
);
157+
const formDataWrapper = createFormData(
158+
{ userId: 123, name: 'Test User' },
159+
{ 'content-type': 'application/json' }
160+
);
161+
const formDataBody: HttpRequestMultipartFormBody = {
162+
type: 'form-data',
163+
content: [
164+
{ key: 'file', value: fileWrapper },
165+
{ key: 'metadata', value: formDataWrapper! },
166+
{ key: 'param1', value: 'value1' },
167+
],
168+
};
169+
170+
const request: HttpRequest = {
171+
method: 'POST',
172+
url: 'http://apimatic.hopto.org:3000/test/requestBuilder',
173+
headers: { 'test-header': 'test-value' },
174+
body: formDataBody,
175+
responseType: 'text',
176+
};
177+
178+
const axiosRequestConfig = httpClient.convertHttpRequest(request);
179+
expect(axiosRequestConfig).toMatchObject({
180+
url: 'http://apimatic.hopto.org:3000/test/requestBuilder',
181+
method: 'POST',
182+
headers: {
183+
'test-header': 'test-value',
184+
'content-type': new RegExp(
185+
'^multipart/form-data; boundary=--------------------------'
186+
),
187+
},
188+
timeout: DEFAULT_TIMEOUT,
189+
responseType: 'text',
190+
data: expect.any(FormData),
191+
});
192+
});
193+
194+
it('converts request with mixed form-data containing multiple FormDataWrappers', async () => {
195+
const httpClient = new HttpClient(AbortError);
196+
// Need FileWrapper to trigger multipart mode
197+
const fileWrapper = new FileWrapper(
198+
fs.createReadStream('test/dummy_file.txt')
199+
);
200+
const formDataWrapper1 = createFormData(
201+
{ type: 'document', status: 'active' },
202+
{ 'content-type': 'application/json' }
203+
);
204+
const formDataWrapper2 = createFormData({
205+
version: 1,
206+
lastModified: '2025-10-31',
207+
});
208+
209+
const formDataBody: HttpRequestMultipartFormBody = {
210+
type: 'form-data',
211+
content: [
212+
{ key: 'file', value: fileWrapper },
213+
{ key: 'metadata', value: formDataWrapper1! },
214+
{ key: 'version_info', value: formDataWrapper2! },
215+
{ key: 'description', value: 'Test upload' },
216+
],
217+
};
218+
219+
const request: HttpRequest = {
220+
method: 'POST',
221+
url: 'http://apimatic.hopto.org:3000/test/upload',
222+
headers: { authorization: 'Bearer token123' },
223+
body: formDataBody,
224+
responseType: 'text',
225+
};
226+
227+
const axiosRequestConfig = httpClient.convertHttpRequest(request);
228+
expect(axiosRequestConfig).toMatchObject({
229+
url: 'http://apimatic.hopto.org:3000/test/upload',
230+
method: 'POST',
231+
headers: {
232+
authorization: 'Bearer token123',
233+
'content-type': new RegExp(
234+
'^multipart/form-data; boundary=--------------------------'
235+
),
236+
},
237+
timeout: DEFAULT_TIMEOUT,
238+
responseType: 'text',
239+
data: expect.any(FormData),
240+
});
241+
});
242+
141243
it('converts request with http stream body(file stream) and http get method', async () => {
142244
const httpClient = new HttpClient(AbortError);
143245
const streamBody: HttpRequestStreamBody = {
@@ -312,6 +414,57 @@ describe('HTTP Client', () => {
312414
});
313415
});
314416

417+
describe('createFormDataOptions', () => {
418+
it('should return headers with content type from formDataWrapper headers', () => {
419+
const formDataWrapper = createFormData(
420+
{ key: 'value' },
421+
{ 'content-type': 'application/json' }
422+
);
423+
424+
const result = createFormDataOptions(formDataWrapper?.headers || {});
425+
426+
expect(result).toEqual({
427+
contentType: 'application/json',
428+
header: {},
429+
});
430+
});
431+
432+
it('should return undefined content type when not provided in headers', () => {
433+
const formDataWrapper = createFormData({ key: 'value' }, {});
434+
435+
const result = createFormDataOptions(formDataWrapper?.headers || {});
436+
437+
expect(result).toEqual({
438+
contentType: undefined,
439+
header: {},
440+
});
441+
});
442+
443+
it('should handle formDataWrapper with no headers', () => {
444+
const formDataWrapper = createFormData({ key: 'value' });
445+
446+
const result = createFormDataOptions(formDataWrapper?.headers || {});
447+
448+
expect(result).toEqual({
449+
header: {},
450+
});
451+
});
452+
453+
it('should handle case-insensitive content-type header lookup', () => {
454+
const formDataWrapper = createFormData(
455+
{ test: 'data' },
456+
{ 'Content-Type': 'application/json; charset=utf-8' }
457+
);
458+
459+
const result = createFormDataOptions(formDataWrapper?.headers || {});
460+
461+
expect(result).toEqual({
462+
contentType: 'application/json; charset=utf-8',
463+
header: {},
464+
});
465+
});
466+
});
467+
315468
describe('test blob type', () => {
316469
test.each([
317470
[

packages/core-interfaces/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
},
6060
"dependencies": {
6161
"@apimatic/file-wrapper": "^0.3.8",
62+
"@apimatic/json-bigint": "^1.2.0",
6263
"tslib": "^2.8.1"
6364
},
6465
"publishConfig": {
@@ -69,4 +70,4 @@
6970
"url": "git@github.com:apimatic/apimatic-js-runtime.git",
7071
"directory": "packages/core-interfaces"
7172
}
72-
}
73+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import JSONBig from '@apimatic/json-bigint';
2+
3+
/**
4+
* Unique symbol used to mark an object as a FormDataWrapper.
5+
*/
6+
const formDataWrapperMarker = Symbol('FormDataWrapper');
7+
8+
/**
9+
* Represents a wrapped form-data object containing the raw data
10+
* and optional headers to be sent with the request.
11+
*/
12+
export interface FormDataWrapper {
13+
[formDataWrapperMarker]: true;
14+
data: any;
15+
headers?: Record<string, string>;
16+
}
17+
18+
/**
19+
* Creates a FormDataWrapper object that encapsulates form-data and optional headers.
20+
*
21+
* @param data The form-data payload or object to be wrapped.
22+
* @param headers Optional headers to include with the form-data.
23+
* @returns A FormDataWrapper instance.
24+
*/
25+
export function createFormData(
26+
data: any,
27+
headers?: Record<string, string>
28+
): FormDataWrapper | undefined {
29+
if (data === null || data === undefined) {
30+
return undefined;
31+
}
32+
return {
33+
[formDataWrapperMarker]: true,
34+
data:
35+
Array.isArray(data) || typeof data === 'object'
36+
? JSONBig.stringify(data)
37+
: data.toString(),
38+
headers,
39+
};
40+
}
41+
42+
/**
43+
* Type guard that checks if a given value is a FormDataWrapper.
44+
*
45+
* @param value The value to validate.
46+
* @returns True if the value is a FormDataWrapper, false otherwise.
47+
*/
48+
export function isFormDataWrapper(value: unknown): value is FormDataWrapper {
49+
return (
50+
typeof value === 'object' &&
51+
value !== null &&
52+
formDataWrapperMarker in value
53+
);
54+
}

packages/core-interfaces/src/httpRequest.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { FileWrapper } from '@apimatic/file-wrapper';
2+
import { FormDataWrapper } from './formDataWrapper';
23
/**
34
* Represents an HTTP request
45
*/
@@ -50,7 +51,10 @@ export interface HttpRequestUrlEncodedFormBody {
5051

5152
export interface HttpRequestMultipartFormBody {
5253
type: 'form-data';
53-
content: Array<{ key: string; value: string | FileWrapper }>;
54+
content: Array<{
55+
key: string;
56+
value: string | FileWrapper | FormDataWrapper;
57+
}>;
5458
}
5559

5660
export interface HttpRequestStreamBody {

0 commit comments

Comments
 (0)