Skip to content

Commit f806fa5

Browse files
author
Lucas Treffenstädt
committed
add option to handle a given set of mime types as blob
1 parent e1a08b4 commit f806fa5

File tree

5 files changed

+217
-30
lines changed

5 files changed

+217
-30
lines changed

src/fetcher.ts

Lines changed: 60 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
Request,
1414
_TypedFetch,
1515
TypedFetch,
16+
ContentTypeDiscriminator,
17+
BlobTypeSelector,
1618
} from './types'
1719

1820
const sendBody = (method: Method) =>
@@ -134,13 +136,18 @@ function getFetchParams(request: Request) {
134136
return { url, init }
135137
}
136138

137-
async function getResponseData(response: Response) {
139+
async function getResponseData(response: Response, isBlob: BlobTypeSelector) {
138140
const contentType = response.headers.get('content-type')
139141
if (response.status === 204 /* no content */) {
140142
return undefined
141143
}
142-
if (contentType && contentType.indexOf('application/json') !== -1) {
143-
return response.json()
144+
if (contentType) {
145+
if (isBlob(contentType)) {
146+
return response.blob()
147+
}
148+
if (contentType.includes('application/json')) {
149+
return response.json()
150+
}
144151
}
145152
const text = await response.text()
146153
try {
@@ -150,25 +157,32 @@ async function getResponseData(response: Response) {
150157
}
151158
}
152159

153-
async function fetchJson(url: string, init: RequestInit): Promise<ApiResponse> {
154-
const response = await fetch(url, init)
155-
156-
const data = await getResponseData(response)
160+
function getFetchResponse(blobTypeSelector: BlobTypeSelector) {
161+
async function fetchResponse(
162+
url: string,
163+
init: RequestInit,
164+
): Promise<ApiResponse> {
165+
const response = await fetch(url, init)
166+
167+
const data = await getResponseData(response, blobTypeSelector)
168+
169+
const result = {
170+
headers: response.headers,
171+
url: response.url,
172+
ok: response.ok,
173+
status: response.status,
174+
statusText: response.statusText,
175+
data,
176+
}
157177

158-
const result = {
159-
headers: response.headers,
160-
url: response.url,
161-
ok: response.ok,
162-
status: response.status,
163-
statusText: response.statusText,
164-
data,
165-
}
178+
if (result.ok) {
179+
return result
180+
}
166181

167-
if (result.ok) {
168-
return result
182+
throw new ApiError(result)
169183
}
170184

171-
throw new ApiError(result)
185+
return fetchResponse
172186
}
173187

174188
function wrapMiddlewares(middlewares: Middleware[], fetch: Fetch): Fetch {
@@ -227,24 +241,47 @@ function createFetch<OP>(fetch: _TypedFetch<OP>): TypedFetch<OP> {
227241
return fun
228242
}
229243

244+
function getBlobTypeSelector(discriminator?: ContentTypeDiscriminator) {
245+
if (!discriminator) {
246+
return () => false
247+
}
248+
if (typeof discriminator === 'function') {
249+
return discriminator
250+
}
251+
let arrayDiscriminator = discriminator
252+
if (!Array.isArray(discriminator)) {
253+
arrayDiscriminator = [discriminator]
254+
}
255+
const arrayOfRegExp = (arrayDiscriminator as Array<string | RegExp>).map(
256+
(expr) => (expr instanceof RegExp ? expr : new RegExp(`^.*${expr}.*$`)),
257+
)
258+
return (contentType: string) =>
259+
Boolean(arrayOfRegExp.find((expr) => expr.test(contentType)))
260+
}
261+
230262
function fetcher<Paths>() {
231263
let baseUrl = ''
232264
let defaultInit: RequestInit = {}
233265
const middlewares: Middleware[] = []
234-
const fetch = wrapMiddlewares(middlewares, fetchJson)
266+
let blobTypeSelector: BlobTypeSelector = () => false
235267

236268
return {
237269
configure: (config: FetchConfig) => {
238270
baseUrl = config.baseUrl || ''
239271
defaultInit = config.init || {}
240272
middlewares.splice(0)
241273
middlewares.push(...(config.use || []))
274+
blobTypeSelector = getBlobTypeSelector(config.asBlob)
242275
},
243276
use: (mw: Middleware) => middlewares.push(mw),
244277
path: <P extends keyof Paths>(path: P) => ({
245278
method: <M extends keyof Paths[P]>(method: M) => ({
246-
create: ((queryParams?: Record<string, true | 1>) =>
247-
createFetch((payload, init) =>
279+
create: ((queryParams?: Record<string, true | 1>) => {
280+
const fetch = wrapMiddlewares(
281+
middlewares,
282+
getFetchResponse(blobTypeSelector),
283+
)
284+
return createFetch((payload, init) =>
248285
fetchUrl({
249286
baseUrl: baseUrl || '',
250287
path: path as string,
@@ -254,7 +291,8 @@ function fetcher<Paths>() {
254291
init: mergeRequestInit(defaultInit, init),
255292
fetch,
256293
}),
257-
)) as CreateFetch<M, Paths[P][M]>,
294+
)
295+
}) as CreateFetch<M, Paths[P][M]>,
258296
}),
259297
}),
260298
}

src/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,19 @@ export type Middleware = (
127127
next: Fetch,
128128
) => Promise<ApiResponse>
129129

130+
export type BlobTypeSelector = (contentType: string) => boolean
131+
132+
export type ContentTypeDiscriminator =
133+
| string
134+
| RegExp
135+
| Array<string | RegExp>
136+
| BlobTypeSelector
137+
130138
export type FetchConfig = {
131139
baseUrl?: string
132140
init?: RequestInit
133141
use?: Middleware[]
142+
asBlob?: ContentTypeDiscriminator
134143
}
135144

136145
export type Request = {

test/fetch.test.ts

Lines changed: 122 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,17 @@ afterAll(() => server.close())
1010

1111
describe('fetch', () => {
1212
const fetcher = Fetcher.for<paths>()
13+
const defaultFetcherConfig = {
14+
baseUrl: 'https://api.backend.dev',
15+
init: {
16+
headers: {
17+
Authorization: 'Bearer token',
18+
},
19+
},
20+
}
1321

1422
beforeEach(() => {
15-
fetcher.configure({
16-
baseUrl: 'https://api.backend.dev',
17-
init: {
18-
headers: {
19-
Authorization: 'Bearer token',
20-
},
21-
},
22-
})
23+
fetcher.configure(defaultFetcherConfig)
2324
})
2425

2526
const expectedHeaders = {
@@ -270,4 +271,117 @@ describe('fetch', () => {
270271
expect(captured.url).toEqual('https://api.backend.dev/bodyquery/1?scalar=a')
271272
expect(captured.body).toEqual('{"list":["b","c"]}')
272273
})
274+
275+
it('GET /blob (with single content type)', async () => {
276+
fetcher.configure({ ...defaultFetcherConfig, asBlob: 'application/pdf' })
277+
278+
const fun = fetcher.path('/blob').method('post').create()
279+
280+
const { data } = await fun(
281+
{ value: 'test' },
282+
{
283+
headers: {
284+
Accept: 'application/pdf',
285+
},
286+
},
287+
)
288+
289+
expect(data).toBeInstanceOf(Blob)
290+
})
291+
292+
it('GET /blob (with single regex)', async () => {
293+
fetcher.configure({
294+
...defaultFetcherConfig,
295+
asBlob: /^application\/(?!json)/,
296+
})
297+
298+
const fun = fetcher.path('/blob').method('post').create()
299+
300+
for (const mimeType of ['application/octet-stream', 'application/zip']) {
301+
const { data } = await fun(
302+
{ value: 'test' },
303+
{
304+
headers: {
305+
Accept: mimeType,
306+
},
307+
},
308+
)
309+
310+
expect(data).toBeInstanceOf(Blob)
311+
}
312+
313+
for (const mimeType of ['text/plain', 'text/plain;charset=utf-8']) {
314+
const { data } = await fun(
315+
{ value: 'test' },
316+
{
317+
headers: {
318+
Accept: mimeType,
319+
},
320+
},
321+
)
322+
323+
expect(typeof data).toBe('string')
324+
}
325+
326+
for (const mimeType of ['text/plain', 'application/json;charset=utf-8']) {
327+
const { data } = await fun(
328+
{ value: JSON.stringify({ value: 'test' }) },
329+
{
330+
headers: {
331+
Accept: mimeType,
332+
},
333+
},
334+
)
335+
336+
expect(data).toEqual({ value: 'test' })
337+
}
338+
})
339+
340+
it('GET /blob (with list of mime types)', async () => {
341+
fetcher.configure({
342+
...defaultFetcherConfig,
343+
asBlob: ['application/octet-stream', 'audio/vorbis'],
344+
})
345+
346+
const fun = fetcher.path('/blob').method('post').create()
347+
348+
for (const mimeType of ['application/octet-stream', 'audio/vorbis']) {
349+
const { data } = await fun(
350+
{ value: 'test' },
351+
{
352+
headers: {
353+
Accept: mimeType,
354+
},
355+
},
356+
)
357+
358+
expect(data).toBeInstanceOf(Blob)
359+
}
360+
})
361+
362+
it('GET /blob (with custom discriminator function', async () => {
363+
fetcher.configure({
364+
...defaultFetcherConfig,
365+
asBlob: (contentType) => contentType.startsWith('application'),
366+
})
367+
368+
const fun = fetcher.path('/blob').method('post').create()
369+
370+
for (const mimeType of [
371+
'application/octet-stream',
372+
'application/zip',
373+
'application/json',
374+
]) {
375+
const { data } = await fun(
376+
{ value: 'test' },
377+
{
378+
headers: {
379+
Accept: mimeType,
380+
},
381+
},
382+
)
383+
384+
expect(data).toBeInstanceOf(Blob)
385+
}
386+
})
273387
})

test/mocks/handlers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,13 @@ function getResult(
3636
)
3737
}
3838

39+
function getBlob(req: RestRequest, res: ResponseComposition, ctx: RestContext) {
40+
return res(
41+
ctx.body(new Blob([(req.body as Record<string, string>)['value']])),
42+
ctx.set('Content-Type', req.headers.get('accept') ?? 'application/pdf'),
43+
)
44+
}
45+
3946
const HOST = 'https://api.backend.dev'
4047

4148
const methods = {
@@ -49,6 +56,10 @@ const methods = {
4956
withBodyAndQuery: ['post', 'put', 'patch', 'delete'].map((method) => {
5057
return (rest as any)[method](`${HOST}/bodyquery/:id`, getResult)
5158
}),
59+
withBlob: [
60+
rest.get(`${HOST}/blob`, getBlob),
61+
rest.post(`${HOST}/blob`, getBlob),
62+
],
5263
withError: [
5364
rest.get(`${HOST}/error/:status`, (req, res, ctx) => {
5465
const status = Number(req.params.status)
@@ -79,6 +90,7 @@ const methods = {
7990
export const handlers = [
8091
...methods.withQuery,
8192
...methods.withBody,
93+
...methods.withBlob,
8294
...methods.withBodyArray,
8395
...methods.withBodyAndQuery,
8496
...methods.withError,

test/paths.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ type BodyAndQuery = {
3939
responses: { 201: { schema: Data } }
4040
}
4141

42+
type BodyBlob = {
43+
parameters: {
44+
body: { payload: { value: string } }
45+
}
46+
responses: {
47+
200: {
48+
schema: unknown
49+
}
50+
}
51+
}
52+
4253
export type paths = {
4354
'/query/{a}/{b}': {
4455
get: Query
@@ -61,6 +72,9 @@ export type paths = {
6172
patch: BodyAndQuery
6273
delete: BodyAndQuery
6374
}
75+
'/blob': {
76+
post: BodyBlob
77+
}
6478
'/nocontent': {
6579
post: {
6680
parameters: {}

0 commit comments

Comments
 (0)