diff --git a/README.md b/README.md index c08640c..c421ebb 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,15 @@ 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 @@ -71,6 +73,9 @@ 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 diff --git a/package.json b/package.json index 5e3f5e5..b3347a4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/fetch.ts b/src/fetch.ts index 24b8cb7..8b918aa 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -2,7 +2,7 @@ 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 } @@ -10,11 +10,12 @@ export type FetchRequest = RequestInfo export interface SearchParams { [key: string]: any } -export interface FetchOptions extends Omit { +export interface FetchOptions extends Omit { baseURL?: string body?: RequestInit['body'] | Record params?: SearchParams parseResponse?: (responseText: string) => any + responseType?: R response?: boolean retry?: number | false } @@ -22,8 +23,8 @@ export interface FetchOptions extends Omit { export interface FetchResponse extends Response { data?: T } export interface $Fetch { - (request: FetchRequest, opts?: FetchOptions): Promise - raw(request: FetchRequest, opts?: FetchOptions): Promise> + (request: FetchRequest, opts?: FetchOptions): Promise> + raw(request: FetchRequest, opts?: FetchOptions): Promise>> } export function setHeader (options: FetchOptions, _key: string, value: string) { @@ -83,9 +84,18 @@ export function createFetch ({ fetch }: CreateFetchOptions): $Fetch { } } const response: FetchResponse = 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) } diff --git a/src/utils.ts b/src/utils.ts index 5237a5d..59cfdee 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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 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' +} diff --git a/test/index.test.mjs b/test/index.test.mjs index 836fc10..6882d5a 100644 --- a/test/index.test.mjs +++ b/test/index.test.mjs @@ -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', () => { @@ -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) }) @@ -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') }) diff --git a/yarn.lock b/yarn.lock index 8e58eae..b91be86 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==