diff --git a/README.md b/README.md index afd1c800..7a7c9514 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,49 @@ try { } ``` +### Binary Data + +It is possible to handle some content types as binary data. +You can specify these as a string, regular expression, list of regular expressions and/or strings, or a custom discriminator function +If one of the strings or regular expressions you provided matches the Content-Type header returned by the endpoint, +or your discriminator function, called with the contents of the Content-Type header, returns `true`, the response body will be returned as a [binary blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob). + +```ts + +fetcher.configure({ + baseUrl: 'https://example.com/api', + asBlob: 'application/octet-stream' +}) + +// or + +fetcher.configure({ + baseUrl: 'https://example.com/api', + asBlob: /^application\/(?!json)/ +}) + +// or + +fetcher.configure({ + baseUrl: 'https://example.com/api', + asBlob: ['application/pdf', 'audio/vorbis'] +}) + +// or + +fetcher.configure({ + baseUrl: 'https://example.com/api', + asBlob: (contentType: string) => contentType.startsWith('application/o') +}) + +// data is going to be a Blob +const { data } = await fetcher.path('/binary/data').method('get').create()( + {}, + { headers: { Accept: 'application/octet-stream' } }, +) + +``` + ### Middleware Middlewares can be used to pre and post process fetch operations (log api calls, add auth headers etc) @@ -190,4 +233,4 @@ const body = arrayRequestBody([{ item: 1}], { param: 2}) // body type is { item: number }[] & { param: number } ``` -Happy fetching! 👍 \ No newline at end of file +Happy fetching! 👍 diff --git a/src/fetcher.ts b/src/fetcher.ts index 1eb1d5f9..8bb71db3 100644 --- a/src/fetcher.ts +++ b/src/fetcher.ts @@ -13,6 +13,8 @@ import { Request, _TypedFetch, TypedFetch, + ContentTypeDiscriminator, + BlobTypeSelector, } from './types' const sendBody = (method: Method) => @@ -134,13 +136,18 @@ function getFetchParams(request: Request) { return { url, init } } -async function getResponseData(response: Response) { +async function getResponseData(response: Response, isBlob: BlobTypeSelector) { const contentType = response.headers.get('content-type') if (response.status === 204 /* no content */) { return undefined } - if (contentType && contentType.indexOf('application/json') !== -1) { - return await response.json() + if (contentType) { + if (isBlob(contentType)) { + return response.blob() + } + if (contentType.includes('application/json')) { + return response.json() + } } const text = await response.text() try { @@ -150,25 +157,32 @@ async function getResponseData(response: Response) { } } -async function fetchJson(url: string, init: RequestInit): Promise { - const response = await fetch(url, init) - - const data = await getResponseData(response) +function getFetchResponse(blobTypeSelector: BlobTypeSelector) { + async function fetchResponse( + url: string, + init: RequestInit, + ): Promise { + const response = await fetch(url, init) + + const data = await getResponseData(response, blobTypeSelector) + + const result = { + headers: response.headers, + url: response.url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + data, + } - const result = { - headers: response.headers, - url: response.url, - ok: response.ok, - status: response.status, - statusText: response.statusText, - data, - } + if (result.ok) { + return result + } - if (result.ok) { - return result + throw new ApiError(result) } - throw new ApiError(result) + return fetchResponse } function wrapMiddlewares(middlewares: Middleware[], fetch: Fetch): Fetch { @@ -227,11 +241,29 @@ function createFetch(fetch: _TypedFetch): TypedFetch { return fun } +function getBlobTypeSelector(discriminator?: ContentTypeDiscriminator) { + if (!discriminator) { + return () => false + } + if (typeof discriminator === 'function') { + return discriminator + } + let arrayDiscriminator = discriminator + if (!Array.isArray(discriminator)) { + arrayDiscriminator = [discriminator] + } + const arrayOfRegExp = (arrayDiscriminator as Array).map( + (expr) => (expr instanceof RegExp ? expr : new RegExp(`^.*${expr}.*$`)), + ) + return (contentType: string) => + Boolean(arrayOfRegExp.find((expr) => expr.test(contentType))) +} + function fetcher() { let baseUrl = '' let defaultInit: RequestInit = {} const middlewares: Middleware[] = [] - const fetch = wrapMiddlewares(middlewares, fetchJson) + let blobTypeSelector: BlobTypeSelector = () => false return { configure: (config: FetchConfig) => { @@ -239,12 +271,17 @@ function fetcher() { defaultInit = config.init || {} middlewares.splice(0) middlewares.push(...(config.use || [])) + blobTypeSelector = getBlobTypeSelector(config.asBlob) }, use: (mw: Middleware) => middlewares.push(mw), path:

(path: P) => ({ method: (method: M) => ({ - create: ((queryParams?: Record) => - createFetch((payload, init) => + create: ((queryParams?: Record) => { + const fetch = wrapMiddlewares( + middlewares, + getFetchResponse(blobTypeSelector), + ) + return createFetch((payload, init) => fetchUrl({ baseUrl: baseUrl || '', path: path as string, @@ -254,7 +291,8 @@ function fetcher() { init: mergeRequestInit(defaultInit, init), fetch, }), - )) as CreateFetch, + ) + }) as CreateFetch, }), }), } diff --git a/src/types.ts b/src/types.ts index 9d51fb00..68dc5ecb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,14 @@ export type OpenapiPaths = { } } +type ApplicationJson = 'application/json' | `application/json;${string}` + +type JsonResponse = T extends Record + ? { + [K in keyof T]: K extends ApplicationJson ? T[K] : never + }[keyof T] + : unknown + export type OpArgType = OP extends { parameters?: { path?: infer P @@ -23,12 +31,13 @@ export type OpArgType = OP extends { } // openapi 3 requestBody?: { - content: { - 'application/json': infer RB - } + content: infer RB } } - ? P & Q & (B extends Record ? B[keyof B] : unknown) & RB + ? P & + Q & + (B extends Record ? B[keyof B] : unknown) & + JsonResponse : Record type OpResponseTypes = OP extends { @@ -127,10 +136,19 @@ export type Middleware = ( next: Fetch, ) => Promise +export type BlobTypeSelector = (contentType: string) => boolean + +export type ContentTypeDiscriminator = + | string + | RegExp + | Array + | BlobTypeSelector + export type FetchConfig = { baseUrl?: string init?: RequestInit use?: Middleware[] + asBlob?: ContentTypeDiscriminator } export type Request = { diff --git a/test/fetch.test.ts b/test/fetch.test.ts index 974dbdda..96e56803 100644 --- a/test/fetch.test.ts +++ b/test/fetch.test.ts @@ -10,16 +10,17 @@ afterAll(() => server.close()) describe('fetch', () => { const fetcher = Fetcher.for() + const defaultFetcherConfig = { + baseUrl: 'https://api.backend.dev', + init: { + headers: { + Authorization: 'Bearer token', + }, + }, + } beforeEach(() => { - fetcher.configure({ - baseUrl: 'https://api.backend.dev', - init: { - headers: { - Authorization: 'Bearer token', - }, - }, - }) + fetcher.configure(defaultFetcherConfig) }) const expectedHeaders = { @@ -270,4 +271,117 @@ describe('fetch', () => { expect(captured.url).toEqual('https://api.backend.dev/bodyquery/1?scalar=a') expect(captured.body).toEqual('{"list":["b","c"]}') }) + + it('GET /blob (with single content type)', async () => { + fetcher.configure({ ...defaultFetcherConfig, asBlob: 'application/pdf' }) + + const fun = fetcher.path('/blob').method('post').create() + + const { data } = await fun( + { value: 'test' }, + { + headers: { + Accept: 'application/pdf', + }, + }, + ) + + expect(data).toBeInstanceOf(Blob) + }) + + it('GET /blob (with single regex)', async () => { + fetcher.configure({ + ...defaultFetcherConfig, + asBlob: /^application\/(?!json)/, + }) + + const fun = fetcher.path('/blob').method('post').create() + + for (const mimeType of ['application/octet-stream', 'application/zip']) { + const { data } = await fun( + { value: 'test' }, + { + headers: { + Accept: mimeType, + }, + }, + ) + + expect(data).toBeInstanceOf(Blob) + } + + for (const mimeType of ['text/plain', 'text/plain;charset=utf-8']) { + const { data } = await fun( + { value: 'test' }, + { + headers: { + Accept: mimeType, + }, + }, + ) + + expect(typeof data).toBe('string') + } + + for (const mimeType of ['text/plain', 'application/json;charset=utf-8']) { + const { data } = await fun( + { value: JSON.stringify({ value: 'test' }) }, + { + headers: { + Accept: mimeType, + }, + }, + ) + + expect(data).toEqual({ value: 'test' }) + } + }) + + it('GET /blob (with list of mime types)', async () => { + fetcher.configure({ + ...defaultFetcherConfig, + asBlob: ['application/octet-stream', 'audio/vorbis'], + }) + + const fun = fetcher.path('/blob').method('post').create() + + for (const mimeType of ['application/octet-stream', 'audio/vorbis']) { + const { data } = await fun( + { value: 'test' }, + { + headers: { + Accept: mimeType, + }, + }, + ) + + expect(data).toBeInstanceOf(Blob) + } + }) + + it('GET /blob (with custom discriminator function', async () => { + fetcher.configure({ + ...defaultFetcherConfig, + asBlob: (contentType) => contentType.startsWith('application'), + }) + + const fun = fetcher.path('/blob').method('post').create() + + for (const mimeType of [ + 'application/octet-stream', + 'application/zip', + 'application/json', + ]) { + const { data } = await fun( + { value: 'test' }, + { + headers: { + Accept: mimeType, + }, + }, + ) + + expect(data).toBeInstanceOf(Blob) + } + }) }) diff --git a/test/infer.test.ts b/test/infer.test.ts index 03ff5cb1..1a867478 100644 --- a/test/infer.test.ts +++ b/test/infer.test.ts @@ -23,6 +23,26 @@ type Op3 = Omit & { } } +type Op4 = Omit & { + requestBody: { + content: { + 'application/json;charset=utf-8': paths3['/v1/account_links']['post']['requestBody']['content']['application/x-www-form-urlencoded'] + } + } + responses: { + 200: { + content: { + 'application/json;charset=UTF-8': paths3['/v1/account_links']['post']['responses']['200']['content']['application/json'] + } + } + default: { + content: { + 'application/json; charset=UTF-8': paths3['/v1/account_links']['post']['responses']['default']['content']['application/json'] + } + } + } +} + interface Openapi2 { Argument: OpArgType Return: OpReturnType @@ -37,6 +57,13 @@ interface Openapi3 { Error: Pick['data']['error'], 'type' | 'message'> } +interface Openapi4 { + Argument: OpArgType + Return: OpReturnType + Default: Pick['error'], 'type' | 'message'> + Error: Pick['data']['error'], 'type' | 'message'> +} + type Same = A extends B ? (B extends A ? true : false) : false describe('infer', () => { @@ -128,4 +155,34 @@ describe('infer', () => { const err: Err = { data: { error: {} } } as any expect(err.data.error.charge).toBeUndefined() }) + + describe('application/json with charset', () => { + it('argument', () => { + const same: Same = true + expect(same).toBe(true) + + // @ts-expect-error -- missing properties + const arg: Openapi4['Argument'] = {} + expect(arg.account).toBeUndefined() + }) + + it('return', () => { + const same: Same = true + expect(same).toBe(true) + + // @ts-expect-error -- missing properties + const ret: Openapi4['Return'] = {} + expect(ret.url).toBeUndefined() + }) + + it('default', () => { + const same: Same = true + expect(same).toBe(true) + }) + + it('error', () => { + const same: Same = true + expect(same).toBe(true) + }) + }) }) diff --git a/test/mocks/handlers.ts b/test/mocks/handlers.ts index 1ea77152..6c0d8be0 100644 --- a/test/mocks/handlers.ts +++ b/test/mocks/handlers.ts @@ -36,6 +36,13 @@ function getResult( ) } +function getBlob(req: RestRequest, res: ResponseComposition, ctx: RestContext) { + return res( + ctx.body(new Blob([(req.body as Record)['value']])), + ctx.set('Content-Type', req.headers.get('accept') ?? 'application/pdf'), + ) +} + const HOST = 'https://api.backend.dev' const methods = { @@ -49,6 +56,10 @@ const methods = { withBodyAndQuery: ['post', 'put', 'patch', 'delete'].map((method) => { return (rest as any)[method](`${HOST}/bodyquery/:id`, getResult) }), + withBlob: [ + rest.get(`${HOST}/blob`, getBlob), + rest.post(`${HOST}/blob`, getBlob), + ], withError: [ rest.get(`${HOST}/error/:status`, (req, res, ctx) => { const status = Number(req.params.status) @@ -79,6 +90,7 @@ const methods = { export const handlers = [ ...methods.withQuery, ...methods.withBody, + ...methods.withBlob, ...methods.withBodyArray, ...methods.withBodyAndQuery, ...methods.withError, diff --git a/test/paths.ts b/test/paths.ts index afc3831a..ecebdec0 100644 --- a/test/paths.ts +++ b/test/paths.ts @@ -39,6 +39,17 @@ type BodyAndQuery = { responses: { 201: { schema: Data } } } +type BodyBlob = { + parameters: { + body: { payload: { value: string } } + } + responses: { + 200: { + schema: unknown + } + } +} + export type paths = { '/query/{a}/{b}': { get: Query @@ -61,6 +72,9 @@ export type paths = { patch: BodyAndQuery delete: BodyAndQuery } + '/blob': { + post: BodyBlob + } '/nocontent': { post: { parameters: {}