Skip to content

Commit

Permalink
feat: return blob if content-type isn't text, svg, xml or json (#39)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Dec 17, 2021
1 parent def366d commit 1029b9e
Show file tree
Hide file tree
Showing 6 changed files with 80 additions and 10 deletions.
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,25 @@ By setting `FETCH_KEEP_ALIVE` environment variable to `true`, A http/https agent

## ✔️ Parsing Response

`$fetch` Smartly parses JSON and native values using [destr](https://github.com/unjs/destr) and fallback to text if it fails to parse.
`$fetch` will smartly parse JSON and native values using [destr](https://github.com/unjs/destr), falling back to text if it fails to parse.

```js
const { users } = await $fetch('/api/users')
```

You can optionally provde a different parser than destr.
For binary content types, `$fetch` will instead return a `Blob` object.

You can optionally provde a different parser than destr, or specify `blob`, `arrayBuffer` or `text` to force parsing the body with the respective `FetchResponse` method.

```js
// Use JSON.parse
await $fetch('/movie?lang=en', { parseResponse: JSON.parse })

// Return text as is
await $fetch('/movie?lang=en', { parseResponse: txt => txt })

// Get the blob version of the response
await $fetch('/api/generate-image', { responseType: 'blob' })
```

## ✔️ JSON Body
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@types/node-fetch": "^3.0.3",
"chai": "latest",
"eslint": "latest",
"fetch-blob": "^3.1.3",
"formdata-polyfill": "^4.0.10",
"h3": "latest",
"jiti": "latest",
Expand Down
24 changes: 17 additions & 7 deletions src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,29 @@ import destr from 'destr'
import { withBase, withQuery } from 'ufo'
import type { Fetch, RequestInfo, RequestInit, Response } from './types'
import { createFetchError } from './error'
import { isPayloadMethod, isJSONSerializable } from './utils'
import { isPayloadMethod, isJSONSerializable, detectResponseType, ResponseType, MappedType } from './utils'

export interface CreateFetchOptions { fetch: Fetch }

export type FetchRequest = RequestInfo

export interface SearchParams { [key: string]: any }

export interface FetchOptions extends Omit<RequestInit, 'body'> {
export interface FetchOptions<R extends ResponseType = ResponseType> extends Omit<RequestInit, 'body'> {
baseURL?: string
body?: RequestInit['body'] | Record<string, any>
params?: SearchParams
parseResponse?: (responseText: string) => any
responseType?: R
response?: boolean
retry?: number | false
}

export interface FetchResponse<T> extends Response { data?: T }

export interface $Fetch {
<T = any>(request: FetchRequest, opts?: FetchOptions): Promise<T>
raw<T = any>(request: FetchRequest, opts?: FetchOptions): Promise<FetchResponse<T>>
<T = any, R extends ResponseType = 'json'>(request: FetchRequest, opts?: FetchOptions<R>): Promise<MappedType<R, T>>
raw<T = any, R extends ResponseType = 'json'>(request: FetchRequest, opts?: FetchOptions<R>): Promise<FetchResponse<MappedType<R, T>>>
}

export function setHeader (options: FetchOptions, _key: string, value: string) {
Expand Down Expand Up @@ -83,9 +84,18 @@ export function createFetch ({ fetch }: CreateFetchOptions): $Fetch {
}
}
const response: FetchResponse<any> = await fetch(request, opts as RequestInit).catch(error => onError(request, opts, error, undefined))
const text = await response.text()
const parseFn = opts.parseResponse || destr
response.data = parseFn(text)

const responseType = opts.parseResponse ? 'json' : opts.responseType || detectResponseType(response.headers.get('content-type') || '')

// We override the `.json()` method to parse the body more securely with `destr`
if (responseType === 'json') {
const data = await response.text()
const parseFn = opts.parseResponse || destr
response.data = parseFn(data)
} else {
response.data = await response[responseType]()
}

return response.ok ? response : onError(request, opts, undefined, response)
}

Expand Down
38 changes: 38 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,41 @@ export function isJSONSerializable (val: any) {
return (val.constructor && val.constructor.name === 'Object') ||
typeof val.toJSON === 'function'
}

const textTypes = new Set([
'image/svg',
'application/xml',
'application/xhtml',
'application/html'
])

const jsonTypes = new Set(['application/json', 'application/ld+json'])

interface ResponseMap {
blob: Blob
text: string
arrayBuffer: ArrayBuffer
}

export type ResponseType = keyof ResponseMap | 'json'
export type MappedType<R extends ResponseType, JsonType = any> = R extends keyof ResponseMap ? ResponseMap[R] : JsonType

// This provides reasonable defaults for the correct parser based on Content-Type header.
export function detectResponseType (_contentType = ''): ResponseType {
if (!_contentType) {
return 'json'
}

// Value might look like: `application/json; charset=utf-8`
const contentType = _contentType.split(';').shift()!

if (jsonTypes.has(contentType)) {
return 'json'
}

if (textTypes.has(contentType) || contentType.startsWith('text/')) {
return 'text'
}

return 'blob'
}
16 changes: 16 additions & 0 deletions test/index.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createApp, useBody } from 'h3'
import { expect } from 'chai'
import { Headers } from 'node-fetch'
import { $fetch } from 'ohmyfetch'
import { Blob } from 'fetch-blob'
import { FormData } from 'formdata-polyfill/esm.min.js'

describe('ohmyfetch', () => {
Expand All @@ -16,6 +17,10 @@ describe('ohmyfetch', () => {
.use('/params', req => (getQuery(req.url || '')))
.use('/url', req => req.url)
.use('/post', async req => ({ body: await useBody(req), headers: req.headers }))
.use('/binary', (_req, res) => {
res.setHeader('Content-Type', 'application/octet-stream')
return new Blob(['binary'])
})
listener = await listen(app)
})

Expand All @@ -34,6 +39,17 @@ describe('ohmyfetch', () => {
expect(called).to.equal(1)
})

it('allows specifying FetchResponse method', async () => {
expect(await $fetch(getURL('params?test=true'), { responseType: 'json' })).to.deep.equal({ test: 'true' })
expect(await $fetch(getURL('params?test=true'), { responseType: 'blob' })).to.be.instanceOf(Blob)
expect(await $fetch(getURL('params?test=true'), { responseType: 'text' })).to.equal('{"test":"true"}')
expect(await $fetch(getURL('params?test=true'), { responseType: 'arrayBuffer' })).to.be.instanceOf(ArrayBuffer)
})

it('returns a blob for binary content-type', async () => {
expect(await $fetch(getURL('binary'))).to.be.instanceOf(Blob)
})

it('baseURL', async () => {
expect(await $fetch('/x?foo=123', { baseURL: getURL('url') })).to.equal('/x?foo=123')
})
Expand Down
2 changes: 1 addition & 1 deletion yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1795,7 +1795,7 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"

fetch-blob@^3.1.2:
fetch-blob@^3.1.2, fetch-blob@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.1.3.tgz#a7dca4855e39d3e3c5a1da62d4ee335c37d26012"
integrity sha512-ax1Y5I9w+9+JiM+wdHkhBoxew+zG4AJ2SvAD1v1szpddUIiPERVGBxrMcB2ZqW0Y3PP8bOWYv2zqQq1Jp2kqUQ==
Expand Down

0 comments on commit 1029b9e

Please sign in to comment.