Skip to content

Commit 0f20078

Browse files
authored
Merge pull request #5 from WJSoftware:JP/ShortCutFunctions
feat: Add shortcut functions for popular HTTP methods
2 parents 275d3be + c798cb1 commit 0f20078

File tree

3 files changed

+246
-3
lines changed

3 files changed

+246
-3
lines changed

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,35 @@ const localFetcher = rootFetcher.clone(true, { includeParsers: false }); // No c
169169
> **IMPORTANT**: The first parameter to the `clone` function cannot be a variable. It is just used as a TypeScript
170170
> trick to reset the body typing. The value itself means nothing in runtime because types are not a runtime thing.
171171
172+
## Shortcut Functions
173+
174+
> Since **v0.3.0**
175+
176+
`DrFetch` objects now provide the shortcut functions `get`, `post`, `patch`, `put` and `delete`. Except for `get`, all
177+
these accept a body parameter. When this body is a POJO or an array, the body is stringified and the `Content-Type`
178+
header is given the value `application/json`. If a body of any other type is given (that the `fetch()` function
179+
accepts, such as `FormData`), no headers are explicitly specified and therefore it is up to what `fetch()` (or the
180+
custom data-fetching function you provide) does in these cases.
181+
182+
```typescript
183+
const newTodo = { text: 'I am new. Insert me!' };
184+
const response = await fetcher
185+
.for<200, { success: boolean; }>()
186+
.for<400, { errors: string[]; }>()
187+
.post('/api/todos', newTodo);
188+
189+
const newTodos = [{ text: 'I am new. Insert me!' }, { text: 'Me too!' }];
190+
const response = await fetcher
191+
.for<200, { success: boolean; }>()
192+
.for<400, { errors: string[]; }>()
193+
.post('/api/todos', newTodos);
194+
```
195+
196+
As stated, your custom fetch can be used to further customize the request because these shortcut functions will, in the
197+
end, call it.
198+
172199
## Usage Without TypeScript (JavaScript Projects)
173200

174201
Why are you a weird fellow/gal? Anyway, prejudice aside, body typing will mean nothing to you, so forget about `for()`
175-
and anything else regarding types. Do your custom data-fetching function, add your custom body parsers and that's it.
202+
and anything else regarding types. Do your custom data-fetching function, add your custom body parsers and fetch away
203+
using `.fetch()`, `.get()`, `.post()`, `.put()`, `.patch()` or `.delete()`.

src/DrFetch.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ const textTypes: (string | RegExp)[] = [
1616
/^text\/.+/,
1717
];
1818

19+
/**
20+
* Determines if the given object is a POJO.
21+
* @param obj Object under test.
22+
* @returns `true` if it is a POJO, or `false` otherwise.
23+
*/
24+
function isPojo(obj: unknown): obj is Record<string, any> {
25+
if (obj === null || typeof obj !== 'object') {
26+
return false;
27+
}
28+
const proto = Object.getPrototypeOf(obj);
29+
if (proto == null) {
30+
return true;
31+
}
32+
return proto === Object.prototype;
33+
}
34+
1935
/**
2036
* # DrFetch
2137
*
@@ -230,4 +246,98 @@ export class DrFetch<T = unknown> {
230246
body
231247
} as T;
232248
}
249+
250+
#processBody(body: BodyInit | null | Record<string, any> | undefined) {
251+
let headers: Record<string, string> = {};
252+
if (isPojo(body) || Array.isArray(body)) {
253+
body = JSON.stringify(body);
254+
headers['content-type'] = 'application/json';
255+
}
256+
return [body as BodyInit | null, headers] as const;
257+
}
258+
259+
/**
260+
* Shortcut method to emit a GET HTTP request.
261+
* @param url URL for the fetch function call.
262+
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
263+
*/
264+
get(url: URL | string) {
265+
return this.fetch(url, { method: 'GET' });
266+
}
267+
268+
/**
269+
* Shortcut method to emit a POST HTTP request.
270+
* @param url URL for the fetch function call.
271+
* @param body The data to send as body.
272+
*
273+
* If a POJO is passed, it will be stringified and the `Content-Type` header of the request will be set to
274+
* `'application/json'`. This is also true with arrays.
275+
*
276+
* > **NOTE**: You must make sure that the POJO or the array (and its elements) you pass as body are serializable.
277+
*
278+
* Any other body type will not generate a `Content-Type` header and will be reliant on what the `fetch()` function
279+
* does in those cases.
280+
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
281+
*/
282+
post(url: URL | string, body?: BodyInit | null | Record<string, any>) {
283+
const [pBody, headers] = this.#processBody(body);
284+
return this.fetch(url, { method: 'POST', body: pBody, headers });
285+
}
286+
287+
/**
288+
* Shortcut method to emit a PATCH HTTP request.
289+
* @param url URL for the fetch function call.
290+
* @param body The data to send as body.
291+
*
292+
* If a POJO is passed, it will be stringified and the `Content-Type` header of the request will be set to
293+
* `'application/json'`. This is also true with arrays.
294+
*
295+
* > **NOTE**: You must make sure that the POJO or the array (and its elements) you pass as body are serializable.
296+
*
297+
* Any other body type will not generate a `Content-Type` header and will be reliant on what the `fetch()` function
298+
* does in those cases.
299+
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
300+
*/
301+
patch(url: URL | string, body?: BodyInit | null | Record<string, any>) {
302+
const [pBody, headers] = this.#processBody(body);
303+
return this.fetch(url, { method: 'PATCH', body: pBody, headers });
304+
}
305+
306+
/**
307+
* Shortcut method to emit a DELETE HTTP request.
308+
* @param url URL for the fetch function call.
309+
* @param body The data to send as body.
310+
*
311+
* If a POJO is passed, it will be stringified and the `Content-Type` header of the request will be set to
312+
* `'application/json'`. This is also true with arrays.
313+
*
314+
* > **NOTE**: You must make sure that the POJO or the array (and its elements) you pass as body are serializable.
315+
*
316+
* Any other body type will not generate a `Content-Type` header and will be reliant on what the `fetch()` function
317+
* does in those cases.
318+
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
319+
*/
320+
delete(url: URL | string, body?: BodyInit | null | Record<string, any>) {
321+
const [pBody, headers] = this.#processBody(body);
322+
return this.fetch(url, { method: 'DELETE', body: pBody, headers });
323+
}
324+
325+
/**
326+
* Shortcut method to emit a PUT HTTP request.
327+
* @param url URL for the fetch function call.
328+
* @param body The data to send as body.
329+
*
330+
* If a POJO is passed, it will be stringified and the `Content-Type` header of the request will be set to
331+
* `'application/json'`. This is also true with arrays.
332+
*
333+
* > **NOTE**: You must make sure that the POJO or the array (and its elements) you pass as body are serializable.
334+
*
335+
* Any other body type will not generate a `Content-Type` header and will be reliant on what the `fetch()` function
336+
* does in those cases.
337+
* @returns A response object with the HTTP response's `ok`, `status`, `statusText` and `body` properties.
338+
*/
339+
put(url: URL | string, body?: BodyInit | null | Record<string, any>) {
340+
const [pBody, headers] = this.#processBody(body);
341+
return this.fetch(url, { method: 'PUT', body: pBody, headers });
342+
}
233343
}

src/tests/DrFetch.test.ts

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ import { describe, test } from "mocha";
33
import { fake } from 'sinon';
44
import { DrFetch } from "../DrFetch.js";
55

6+
const shortcutMethodsWithBody = [
7+
'post',
8+
'put',
9+
'patch',
10+
'delete',
11+
] as const;
12+
13+
const allShortcutMethods = [
14+
'get',
15+
...shortcutMethodsWithBody
16+
] as const;
17+
618
describe('DrFetch', () => {
719
describe('clone()', () => {
820
[
@@ -162,7 +174,7 @@ describe('DrFetch', () => {
162174
});
163175
test("Should throw an error if the content type is unknown by the built-in parsers and custom parsers.", async () => {
164176
// Arrange.
165-
const fetchFn = fake.resolves(new Response('x', { headers: { 'content-type': 'application/xml' }}));
177+
const fetchFn = fake.resolves(new Response('x', { headers: { 'content-type': 'application/xml' } }));
166178
const fetcher = new DrFetch(fetchFn);
167179
let didThrow = false;
168180

@@ -213,7 +225,7 @@ describe('DrFetch', () => {
213225
test(`Should use the provided custom parser with ${tc.patternType} pattern "${tc.pattern.toString()}" for content type "${tc.contentType}".`, async () => {
214226
// Arrange.
215227
const parserFn = fake();
216-
const fetchFn = fake.resolves(new Response('x', { headers: { 'content-type': tc.contentType }}));
228+
const fetchFn = fake.resolves(new Response('x', { headers: { 'content-type': tc.contentType } }));
217229
const fetcher = new DrFetch(fetchFn);
218230
fetcher.withParser(tc.pattern, parserFn);
219231

@@ -225,4 +237,97 @@ describe('DrFetch', () => {
225237
});
226238
})
227239
});
240+
describe('Shortcut Functions', () => {
241+
allShortcutMethods.map(x => ({
242+
shortcutFn: x,
243+
expectedMethod: x.toUpperCase()
244+
})).forEach(tc => {
245+
test(`${tc.shortcutFn}(): Should perform a fetch() call with the '${tc.expectedMethod}' method.`, async () => {
246+
// Arrange.
247+
const fetchFn = fake.resolves(new Response());
248+
const fetcher = new DrFetch(fetchFn);
249+
250+
// Act.
251+
await fetcher[tc.shortcutFn]('x');
252+
253+
// Assert.
254+
expect(fetchFn.calledOnce).to.be.true;
255+
expect(fetchFn.args[0][1]['method']).to.equal(tc.expectedMethod);
256+
});
257+
});
258+
shortcutMethodsWithBody.forEach(method => {
259+
test(`${method}(): Should stringify the body argument when said argument is a POJO object.`, async () => {
260+
// Arrange.
261+
const body = { a: 'hi' };
262+
const fetchFn = fake.resolves(new Response());
263+
const fetcher = new DrFetch(fetchFn);
264+
265+
// Act.
266+
await fetcher[method]('x', body);
267+
268+
// Assert.
269+
expect(fetchFn.calledOnce).to.be.true;
270+
expect(fetchFn.args[0][1]['body']).to.equal(JSON.stringify(body));
271+
expect(fetchFn.args[0][1]['headers']['content-type']).to.equal('application/json');
272+
});
273+
});
274+
shortcutMethodsWithBody.forEach(method => {
275+
test(`${method}(): Should stringify the body argument when said argument is an array.`, async () => {
276+
// Arrange.
277+
const body = [{ a: 'hi' }];
278+
const fetchFn = fake.resolves(new Response());
279+
const fetcher = new DrFetch(fetchFn);
280+
281+
// Act.
282+
await fetcher[method]('x', body);
283+
284+
// Assert.
285+
expect(fetchFn.calledOnce).to.be.true;
286+
expect(fetchFn.args[0][1]['body']).to.equal(JSON.stringify(body));
287+
expect(fetchFn.args[0][1]['headers']['content-type']).to.equal('application/json');
288+
});
289+
});
290+
shortcutMethodsWithBody.flatMap(method => [
291+
{
292+
body: new ReadableStream(),
293+
text: 'a readable stream',
294+
},
295+
{
296+
body: new Blob(),
297+
text: 'a blob',
298+
},
299+
{
300+
body: new ArrayBuffer(8),
301+
text: 'an array buffer',
302+
},
303+
{
304+
body: new FormData(),
305+
text: 'a form data object',
306+
},
307+
{
308+
body: new URLSearchParams(),
309+
text: 'a URL search params object',
310+
},
311+
{
312+
body: 'abc',
313+
text: 'a string'
314+
}
315+
].map(body => ({
316+
method,
317+
body
318+
}))).forEach(tc => {
319+
test(`${tc.method}(): Should not stringify the body when said argument is ${tc.body.text}.`, async () => {
320+
// Arrange.
321+
const fetchFn = fake.resolves(new Response());
322+
const fetcher = new DrFetch(fetchFn);
323+
324+
// Act.
325+
await fetcher[tc.method]('x', tc.body.body);
326+
327+
// Assert.
328+
expect(fetchFn.calledOnce).to.be.true;
329+
expect(fetchFn.args[0][1]['body']).to.equal(tc.body.body);
330+
});
331+
})
332+
});
228333
});

0 commit comments

Comments
 (0)