Skip to content

Support file downloads #46

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -190,4 +233,4 @@ const body = arrayRequestBody([{ item: 1}], { param: 2})
// body type is { item: number }[] & { param: number }
```

Happy fetching! 👍
Happy fetching! 👍
82 changes: 60 additions & 22 deletions src/fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
Request,
_TypedFetch,
TypedFetch,
ContentTypeDiscriminator,
BlobTypeSelector,
} from './types'

const sendBody = (method: Method) =>
Expand Down Expand Up @@ -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 {
Expand All @@ -150,25 +157,32 @@ async function getResponseData(response: Response) {
}
}

async function fetchJson(url: string, init: RequestInit): Promise<ApiResponse> {
const response = await fetch(url, init)

const data = await getResponseData(response)
function getFetchResponse(blobTypeSelector: BlobTypeSelector) {
async function fetchResponse(
url: string,
init: RequestInit,
): Promise<ApiResponse> {
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 {
Expand Down Expand Up @@ -227,24 +241,47 @@ function createFetch<OP>(fetch: _TypedFetch<OP>): TypedFetch<OP> {
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<string | RegExp>).map(
(expr) => (expr instanceof RegExp ? expr : new RegExp(`^.*${expr}.*$`)),
)
return (contentType: string) =>
Boolean(arrayOfRegExp.find((expr) => expr.test(contentType)))
}

function fetcher<Paths>() {
let baseUrl = ''
let defaultInit: RequestInit = {}
const middlewares: Middleware[] = []
const fetch = wrapMiddlewares(middlewares, fetchJson)
let blobTypeSelector: BlobTypeSelector = () => false

return {
configure: (config: FetchConfig) => {
baseUrl = config.baseUrl || ''
defaultInit = config.init || {}
middlewares.splice(0)
middlewares.push(...(config.use || []))
blobTypeSelector = getBlobTypeSelector(config.asBlob)
},
use: (mw: Middleware) => middlewares.push(mw),
path: <P extends keyof Paths>(path: P) => ({
method: <M extends keyof Paths[P]>(method: M) => ({
create: ((queryParams?: Record<string, true | 1>) =>
createFetch((payload, init) =>
create: ((queryParams?: Record<string, true | 1>) => {
const fetch = wrapMiddlewares(
middlewares,
getFetchResponse(blobTypeSelector),
)
return createFetch((payload, init) =>
fetchUrl({
baseUrl: baseUrl || '',
path: path as string,
Expand All @@ -254,7 +291,8 @@ function fetcher<Paths>() {
init: mergeRequestInit(defaultInit, init),
fetch,
}),
)) as CreateFetch<M, Paths[P][M]>,
)
}) as CreateFetch<M, Paths[P][M]>,
}),
}),
}
Expand Down
26 changes: 22 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export type OpenapiPaths<Paths> = {
}
}

type ApplicationJson = 'application/json' | `application/json;${string}`

type JsonResponse<T> = T extends Record<string, unknown>
? {
[K in keyof T]: K extends ApplicationJson ? T[K] : never
}[keyof T]
: unknown

export type OpArgType<OP> = OP extends {
parameters?: {
path?: infer P
Expand All @@ -23,12 +31,13 @@ export type OpArgType<OP> = OP extends {
}
// openapi 3
requestBody?: {
content: {
'application/json': infer RB
}
content: infer RB
}
}
? P & Q & (B extends Record<string, unknown> ? B[keyof B] : unknown) & RB
? P &
Q &
(B extends Record<string, unknown> ? B[keyof B] : unknown) &
JsonResponse<RB>
: Record<string, never>

type OpResponseTypes<OP> = OP extends {
Expand Down Expand Up @@ -127,10 +136,19 @@ export type Middleware = (
next: Fetch,
) => Promise<ApiResponse>

export type BlobTypeSelector = (contentType: string) => boolean

export type ContentTypeDiscriminator =
| string
| RegExp
| Array<string | RegExp>
| BlobTypeSelector

export type FetchConfig = {
baseUrl?: string
init?: RequestInit
use?: Middleware[]
asBlob?: ContentTypeDiscriminator
}

export type Request = {
Expand Down
130 changes: 122 additions & 8 deletions test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,17 @@ afterAll(() => server.close())

describe('fetch', () => {
const fetcher = Fetcher.for<paths>()
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 = {
Expand Down Expand Up @@ -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)
}
})
})
Loading