Skip to content

Commit 22235d1

Browse files
authored
Merge pull request #40 from kwhitley/v0.9
v0.9 - WIP
2 parents df8ac9a + 48f1acc commit 22235d1

File tree

4 files changed

+123
-62
lines changed

4 files changed

+123
-62
lines changed

.eslintrc

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
],
1212
"rules": {
1313
"@typescript-eslint/ban-types": "off",
14-
"@typescript-eslint/no-explicit-any": "off"
14+
"@typescript-eslint/no-explicit-any": "off",
15+
"@typescript-eslint/no-unused-vars": "off",
16+
"prefer-const": "off",
17+
"quotes": ["error", "single", { "allowTemplateLiterals": true }],
18+
"semi": ["error", "never"]
1519
}
1620
}

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![NPM Weekly Downloads](https://img.shields.io/npm/dw/itty-fetcher?style=flat-square)](https://npmjs.com/package/itty-fetcher)
88
[![Open Issues](https://img.shields.io/github/issues/kwhitley/itty-fetcher?style=flat-square)](https://github.com/kwhitley/itty-fetcher/issues)
99

10-
[![Discord](https://img.shields.io/discord/832353585802903572?style=flat-square)](https://discord.com/channels/832353585802903572)
10+
[![Discord](https://img.shields.io/discord/832353585802903572?style=flat-square)](https://discord.gg/WQnqAsjhd6)
1111
[![GitHub Repo stars](https://img.shields.io/github/stars/kwhitley/itty-fetcher?style=social)](https://github.com/kwhitley/itty-fetcher)
1212
[![Twitter](https://img.shields.io/twitter/follow/kevinrwhitley.svg?style=social&label=Follow)](https://www.twitter.com/kevinrwhitley)
1313

src/index.spec.ts

Lines changed: 84 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import 'isomorphic-fetch'
33
import { beforeEach, describe, expect, it, vi } from 'vitest'
44
import { FetcherOptions, RequestPayload, fetcher } from './index'
55

6+
const RANDOM_STRING = String(Math.random())
7+
68
describe('fetcher', () => {
79
beforeEach(() => {
810
fetchMock.reset()
@@ -15,7 +17,7 @@ describe('fetcher', () => {
1517

1618
describe('config options', () => {
1719
describe('base', () => {
18-
it("defaults to ''", () => {
20+
it(`defaults to ''`, () => {
1921
expect(fetcher().base).toBe('')
2022
})
2123

@@ -50,9 +52,69 @@ describe('fetcher', () => {
5052
expect(custom_fetch).toBeCalledWith('/foo', {
5153
method: 'GET',
5254
body: undefined,
53-
headers: { 'content-type': 'application/json' },
55+
headers: {},
56+
})
57+
})
58+
})
59+
60+
describe('transformRequest', () => {
61+
it('can modify the request before sending', async () => {
62+
fetchMock.get('/', { status: 200, body: 'Success' })
63+
64+
const transformRequest = vi.fn(r => {
65+
r.url = '/'
66+
return r
5467
})
68+
const response = await fetcher({ transformRequest }).get('/foo')
69+
70+
expect(transformRequest).toHaveBeenCalled()
71+
expect(response).toBe('Success')
72+
})
73+
74+
it('can take an async function', async () => {
75+
fetchMock.get('/', { status: 200, body: 'Success' })
76+
77+
const transformRequest = async (r) => {
78+
r.url = await '/'
79+
return r
80+
}
81+
82+
const response = await fetcher({ transformRequest }).get('/foo')
83+
84+
expect(response).toBe('Success')
85+
})
86+
})
87+
})
88+
89+
describe('error handling', () => {
90+
it('without response body', async () => {
91+
fetchMock.get('/', { status: 404 })
92+
const response: any = await fetcher().get('/').catch(e => e)
93+
94+
expect(response.status).toBe(404)
95+
expect(response.message).toBe('Not Found')
96+
})
97+
98+
it('with text response body', async () => {
99+
fetchMock.get('/', { status: 404, body: RANDOM_STRING })
100+
const response: any = await fetcher().get('/').catch(e => e)
101+
102+
expect(response.status).toBe(404)
103+
expect(response.message).toBe(RANDOM_STRING)
104+
})
105+
106+
it('with JSON response body', async () => {
107+
fetchMock.get('/', {
108+
status: 404,
109+
body: { error: 'Not Found', details: RANDOM_STRING },
110+
headers: { 'content-type': 'application/json' },
55111
})
112+
const catchError = vi.fn(e => e)
113+
const response: any = await fetcher().get('/').catch(catchError)
114+
115+
expect(response.status).toBe(404)
116+
expect(response.details).toBe(RANDOM_STRING)
117+
expect(response.error).toBe('Not Found')
56118
})
57119
})
58120

@@ -174,7 +236,6 @@ describe('fetcher', () => {
174236
status: 500,
175237
expected: { error: 'Internal Server Error' },
176238
},
177-
178239
// global headers
179240
'can set a header via global options as well as per-route overrides': {
180241
url: '/foo',
@@ -217,17 +278,17 @@ describe('fetcher', () => {
217278
expected: { url: base + '/?message=hello+world' },
218279
},
219280
'combines query params from the URL and the payload (object)': {
220-
payload: { foo: 10 },
281+
url: '/somewhere?foo=10',
282+
payload: { foo: 12, bar: 'baz' },
221283
options: {
222284
base,
223285
transformRequest(req) {
224-
const url = new URL(req.url)
225-
url.searchParams.set('message', 'hello world')
226-
req.url = url.toString()
286+
console.log('base', base)
287+
console.log('REQUEST', req.url)
227288
return req
228289
},
229290
},
230-
expected: { url: base + '/?foo=10&message=hello+world' },
291+
expected: { url: base + '/somewhere?foo=10&foo=12&bar=baz' },
231292
},
232293
'combines query params from the URL and the payload (URLSearchParams)': {
233294
payload: new URLSearchParams([
@@ -304,6 +365,11 @@ describe('fetcher', () => {
304365
if (expected.error && status) {
305366
fetchMock.mock(full_url, status)
306367
await expect(fetcher(options)[method](url, payload, init)).rejects.toThrow(expected.error)
368+
369+
if (expected.response) {
370+
const getError = vi.fn(({ response, ...err }) => ({ ...err }))
371+
await expect(getError).toHaveReturnedWith(expected.response)
372+
}
307373
return
308374
}
309375

@@ -374,11 +440,16 @@ function create_mock_url({
374440
// query params in the URL. To do this, we need to evaluate the payload
375441
// and add it to the URL.
376442
if (method === 'get' && payload && typeof payload === 'object' && !options?.transformRequest) {
377-
const url = new URL(mock_url)
378-
url.search = (
379-
payload instanceof URLSearchParams ? payload : params_from_object(payload)
380-
).toString()
381-
mock_url = url.toString()
443+
try {
444+
const url = new URL(mock_url)
445+
url.search = (
446+
payload instanceof URLSearchParams ? payload : params_from_object(payload)
447+
).toString()
448+
mock_url = url.toString()
449+
} catch (err) {
450+
console.error(`Could not create url from ${mock_url}.`)
451+
}
382452
}
453+
383454
return mock_url
384455
}

src/index.ts

Lines changed: 33 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ export type RequestPayload = StringifyPayload | PassThroughPayload
2525
export interface FetcherOptions {
2626
base?: string
2727
autoParse?: boolean
28-
transformRequest?: (request: RequestLike) => RequestLike,
29-
handleResponse?: (response: Response) => any,
28+
transformRequest?: (request: RequestLike) => RequestLike | Promise<RequestLike>
29+
handleResponse?: (response: Response) => any
3030
fetch?: typeof fetch
3131
headers?: Record<string, string>
3232
}
@@ -52,9 +52,7 @@ export type FetchyOptions = { method: string } & FetcherOptions
5252

5353
const fetchy =
5454
(options: FetchyOptions): FetchyFunction =>
55-
(url_or_path: string, payload?: RequestPayload, fetchOptions?: FetchOptions) => {
56-
const method = options.method.toUpperCase()
57-
55+
async (url_or_path: string, payload?: RequestPayload, fetchOptions?: FetchOptions) => {
5856
/**
5957
* If the request is a `.get(...)` then we want to pass the payload
6058
* to the URL as query params as passing data in the body is not
@@ -67,40 +65,33 @@ const fetchy =
6765
* We clear the payload after this so that it doesn't get passed to the body.
6866
*/
6967
// let url = new URL(url_or_path)
70-
let search = ''
71-
72-
if (method === 'GET' && payload && typeof payload === 'object') {
7368

74-
const query = url_or_path.split('?')[0] || ''
75-
const merged = new URLSearchParams(query)
76-
77-
const entries = payload instanceof URLSearchParams
78-
// @ts-expect-error - ignore this
79-
? Array.from(payload.entries())
80-
: Object.entries(payload)
69+
const method = options.method.toUpperCase()
70+
let [urlBase, queryParams = ''] = url_or_path.split('?')
8171

82-
// @ts-expect-error - ignore this
83-
for (const [key, value] of entries) {
84-
merged.append(key, value)
85-
}
72+
if (method === 'GET' && payload && typeof payload === 'object') {
73+
const merged = new URLSearchParams(queryParams)
8674

87-
search = merged.toString() ? '?' + merged : ''
88-
payload = undefined
75+
// @ts-expect-error ignore this
76+
const entries = (payload instanceof URLSearchParams ? payload : new URLSearchParams(payload)).entries()
77+
for (let [key, value] of entries) merged.append(key, value)
8978

79+
queryParams = '?' + merged.toString()
80+
payload = null
9081
}
9182

92-
const full_url = (options.base || '') + url_or_path + search
83+
const full_url = (options.base || '') + urlBase + queryParams
9384

9485
/**
9586
* If the payload is a POJO, an array or a string, we will stringify it
9687
* automatically, otherwise we will pass it through as-is.
9788
*/
89+
90+
const t = typeof payload
9891
const stringify =
99-
typeof payload === 'undefined' ||
100-
typeof payload === 'string' ||
101-
typeof payload === 'number' ||
92+
t === 'number' ||
10293
Array.isArray(payload) ||
103-
Object.getPrototypeOf(payload).constructor.name === 'Object'
94+
payload?.constructor === Object
10495

10596
const jsonHeaders = stringify ? { 'content-type': 'application/json' } : undefined
10697

@@ -121,27 +112,28 @@ const fetchy =
121112
* in the options. This allows the user to modify the request before it is
122113
* sent.
123114
*/
124-
if (options.transformRequest) req = options.transformRequest(req)
115+
if (options.transformRequest) req = await options.transformRequest(req)
125116

126117
const { url, ...init } = req
127118

128-
const f = typeof options?.fetch === 'function' ? options.fetch : fetch
119+
const f = options?.fetch || fetch
120+
let error
129121

130-
return f(url, init).then((response) => {
131-
if (options.handleResponse)
132-
return options.handleResponse(response)
122+
return f(url, init).then(async (response) => {
123+
if (options.handleResponse) return options.handleResponse(response)
133124

134125
if (!response.ok) {
135-
throw new StatusError(response.status, response.statusText)
136-
}
126+
error = new StatusError(response.status, response.statusText)
127+
} else if (!options.autoParse) return response
137128

138-
if (!options.autoParse) return response
129+
const content = await (response.headers.get('content-type')?.includes('json') ? response.json() : response.text())
139130

140-
const contentType = response.headers.get('content-type')
131+
if (error) {
132+
Object.assign(error, typeof content === 'object' ? content : { message: content || error.message })
133+
throw error
134+
}
141135

142-
return contentType?.includes('json')
143-
? response.json()
144-
: response.text()
136+
return content
145137
})
146138
}
147139

@@ -150,16 +142,10 @@ export function fetcher(fetcherOptions?: FetcherOptions) {
150142
{
151143
base: '',
152144
autoParse: true,
153-
...fetcherOptions,
145+
...fetcherOptions
154146
},
155147
{
156-
get: (obj, prop: string) =>
157-
obj[prop] !== undefined
158-
? obj[prop]
159-
: fetchy({
160-
method: prop,
161-
...obj,
162-
}),
163-
},
148+
get: (obj, prop: string) => obj[prop] ?? fetchy({ method: prop, ...obj })
149+
}
164150
)
165151
}

0 commit comments

Comments
 (0)