Skip to content

Commit

Permalink
Fix multipart form submission (#26)
Browse files Browse the repository at this point in the history
  • Loading branch information
dillonredding authored Apr 20, 2023
1 parent 340f8aa commit 33519f1
Show file tree
Hide file tree
Showing 7 changed files with 37 additions and 44 deletions.
7 changes: 0 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@
"reflect-metadata": "^0.1.13"
},
"devDependencies": {
"@golevelup/ts-jest": "^0.3.4",
"@types/jest": "^29.2.5",
"@typescript-eslint/eslint-plugin": "^5.49.0",
"@typescript-eslint/parser": "^5.49.0",
Expand Down
14 changes: 6 additions & 8 deletions src/serialize/default-serializer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { createMock } from '@golevelup/ts-jest';

import { nameField } from '../../test/stubs';
import { defaultSerializer } from './default-serializer';
import { fieldsToNameValuePairs } from './fields-to-name-value-pairs';
Expand Down Expand Up @@ -33,12 +31,12 @@ describe('defaultSerializer', () => {

it('should serialize application/x-www-form-urlencoded', async () => {
const type = 'application/x-www-form-urlencoded';
const params = createMock<URLSearchParams>();
mockedSerializeUrlEncodedForm.mockReturnValueOnce(params);
const content = new URLSearchParams();
mockedSerializeUrlEncodedForm.mockReturnValueOnce(content);

const result = await defaultSerializer(type, fields);

expect(result).toStrictEqual({ contentType: type, content: params });
expect(result).toEqual({ content: content, contentType: type });
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledTimes(1);
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledWith(fields);
expect(mockedSerializeUrlEncodedForm).toHaveBeenCalledTimes(1);
Expand All @@ -47,12 +45,12 @@ describe('defaultSerializer', () => {

it('should serialize multipart/form-data', async () => {
const type = 'multipart/form-data';
const content = 'foo';
const content = new FormData();
mockedSerializeMultipartFormData.mockReturnValueOnce(content);

const result = await defaultSerializer(type, fields);

expect(result).toEqual({ contentType: type, content });
expect(result).toEqual({ content, contentType: undefined });
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledTimes(1);
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledWith(fields);
expect(mockedSerializeMultipartFormData).toHaveBeenCalledTimes(1);
Expand All @@ -66,7 +64,7 @@ describe('defaultSerializer', () => {

const result = await defaultSerializer(type, fields);

expect(result).toEqual({ contentType: type, content });
expect(result).toEqual({ content, contentType: type });
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledTimes(1);
expect(mockedFieldsToNamedValuePairs).toHaveBeenCalledWith(fields);
expect(mockedSerializePlainText).toHaveBeenCalledTimes(1);
Expand Down
15 changes: 9 additions & 6 deletions src/serialize/default-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ const typeToSerializeFn = new Map<string, SerializeFn>([
export const defaultSerializer: Serializer = (type: string, fields: Field[]): Promise<Serialization> =>
new Promise((resolve, reject) => {
const serialize = typeToSerializeFn.get(type);
return serialize == null
? reject(new UnsupportedSerializerTypeError(type))
: resolve({
contentType: type,
content: serialize(fieldsToNameValuePairs(fields))
});
if (serialize == null) return reject(new UnsupportedSerializerTypeError(type));

const serialization = serialize(fieldsToNameValuePairs(fields));

return resolve({
content: serialization,
// let the Fetch API generate a boundary parameter for FormData
contentType: serialization instanceof FormData ? undefined : type
});
});
9 changes: 7 additions & 2 deletions src/serialize/serialization.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
/**
* Result of serializing and {@link Action}
* Result of serializing an {@link Action}
*/
export interface Serialization {
contentType: string;
content: BodyInit;

/**
* Media type of `content`. Omit this value to let the Fetch API generate the
* `Content-Type` header.
*/
contentType?: string;
}
31 changes: 12 additions & 19 deletions src/submit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ describe('submit', () => {
const baseUrl = 'https://api.example.com';
const path = '/foo';
const url = new URL(path, baseUrl).toString();
const responseBody = 'Success!';

beforeEach(() => {
if (!nock.isActive()) {
Expand Down Expand Up @@ -38,13 +37,12 @@ describe('submit', () => {
)}`;

it('should make HTTP GET request when method is missing', async () => {
const scope = nock(baseUrl).get(path).reply(200, responseBody);
const scope = nock(baseUrl).get(path).reply(204);

const response = await submit(action);

expect(response.url).toBe(url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});

Expand All @@ -54,13 +52,12 @@ describe('submit', () => {
action.href = url;
action.method = method;
action.fields = [nameField, emailField];
const scope = nock(baseUrl).intercept(`${path}?${urlEncodedForm}`, method).reply(200, responseBody);
const scope = nock(baseUrl).intercept(`${path}?${urlEncodedForm}`, method).reply(204);

const response = await submit(action);

expect(response.url).toBe(`${url}?${urlEncodedForm}`);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});

Expand All @@ -70,13 +67,12 @@ describe('submit', () => {
action.href = url;
action.method = method;
action.fields = [nameField, emailField];
const scope = nock(baseUrl).intercept(path, method, urlEncodedForm).reply(200, responseBody);
const scope = nock(baseUrl).intercept(path, method, urlEncodedForm).reply(204);

const response = await submit(action);

expect(response.url).toBe(url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});

Expand All @@ -96,40 +92,37 @@ describe('submit', () => {
const serializer: Serializer = () => Promise.resolve({ content, contentType });
const scope = nock(baseUrl, { reqheaders: { 'Content-Type': contentType } })
.post(path, content)
.reply(200, responseBody);
.reply(204);

const response = await submit(action, { serializer });

expect(response.url).toBe(url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});

it('should accept and send request options', async () => {
const apiKey = 'foo-bar-baz';
const headers = { 'Api-Key': apiKey };
const scope = nock(baseUrl, { reqheaders: headers }).get(path).reply(200, responseBody);
const scope = nock(baseUrl, { reqheaders: headers }).get(path).reply(204);

const response = await submit(action, { requestInit: { headers } });

expect(response.url).toBe(url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});

it('should resolve relative URL', async () => {
const action = new Action();
action.name = 'do-something';
action.href = path;
const scope = nock(baseUrl).get(path).reply(200, responseBody);
const scope = nock(baseUrl).get(path).reply(204);

const response = await submit(action, { baseUrl });

expect(response.url).toBe(url);
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe(responseBody);
expect(response.status).toBe(204);
expect(scope.isDone()).toBe(true);
});
});
4 changes: 3 additions & 1 deletion src/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@ export async function submit(action: Action, options: SubmitOptions = {}): Promi
if (init.method === 'GET' || init.method === 'DELETE') {
target.search = serialization.content.toString();
} else {
init.headers = { ...init.headers, 'Content-Type': serialization.contentType };
if (serialization.contentType) {
init.headers = { ...init.headers, 'Content-Type': serialization.contentType };
}
init.body = serialization.content;
}
}
Expand Down

0 comments on commit 33519f1

Please sign in to comment.