Skip to content

Commit 891375b

Browse files
authored
refactor: allow download as stream (#137)
* refactor: allow download as stream * feat: fluent api for downloading as stream * test: stream download error cases
1 parent ae7bbb0 commit 891375b

File tree

6 files changed

+144
-41
lines changed

6 files changed

+144
-41
lines changed

src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { StorageError } from './errors'
2+
13
export type BucketType = 'STANDARD' | 'ANALYTICS'
24

35
export interface Bucket {
@@ -164,3 +166,13 @@ type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}
164166
export type Camelize<T> = {
165167
[K in keyof T as CamelCase<Extract<K, string>>]: T[K]
166168
}
169+
170+
export type DownloadResult<T> =
171+
| {
172+
data: T
173+
error: null
174+
}
175+
| {
176+
data: null
177+
error: StorageError
178+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { isStorageError } from '../lib/errors'
2+
import { DownloadResult } from '../lib/types'
3+
import StreamDownloadBuilder from './StreamDownloadBuilder'
4+
5+
export default class BlobDownloadBuilder implements PromiseLike<DownloadResult<Blob>> {
6+
constructor(private downloadFn: () => Promise<Response>, private shouldThrowOnError: boolean) {}
7+
8+
asStream(): StreamDownloadBuilder {
9+
return new StreamDownloadBuilder(this.downloadFn, this.shouldThrowOnError)
10+
}
11+
12+
then<TResult1 = DownloadResult<Blob>, TResult2 = never>(
13+
onfulfilled?: ((value: DownloadResult<Blob>) => TResult1 | PromiseLike<TResult1>) | null,
14+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
15+
): Promise<TResult1 | TResult2> {
16+
return this.execute().then(onfulfilled, onrejected)
17+
}
18+
19+
private async execute(): Promise<DownloadResult<Blob>> {
20+
try {
21+
const result = await this.downloadFn()
22+
23+
return {
24+
data: await result.blob(),
25+
error: null,
26+
}
27+
} catch (error) {
28+
if (this.shouldThrowOnError) {
29+
throw error
30+
}
31+
32+
if (isStorageError(error)) {
33+
return { data: null, error }
34+
}
35+
36+
throw error
37+
}
38+
}
39+
}

src/packages/StorageFileApi.ts

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
SearchV2Options,
1414
SearchV2Result,
1515
} from '../lib/types'
16+
import BlobDownloadBuilder from './BlobDownloadBuilder'
1617

1718
const DEFAULT_SEARCH_OPTIONS = {
1819
limit: 100,
@@ -522,42 +523,21 @@ export default class StorageFileApi {
522523
* @param path The full path and file name of the file to be downloaded. For example `folder/image.png`.
523524
* @param options.transform Transform the asset before serving it to the client.
524525
*/
525-
async download(
526+
download<Options extends { transform?: TransformOptions }>(
526527
path: string,
527-
options?: { transform?: TransformOptions }
528-
): Promise<
529-
| {
530-
data: Blob
531-
error: null
532-
}
533-
| {
534-
data: null
535-
error: StorageError
536-
}
537-
> {
528+
options?: Options
529+
): BlobDownloadBuilder {
538530
const wantsTransformation = typeof options?.transform !== 'undefined'
539531
const renderPath = wantsTransformation ? 'render/image/authenticated' : 'object'
540532
const transformationQuery = this.transformOptsToQueryString(options?.transform || {})
541533
const queryString = transformationQuery ? `?${transformationQuery}` : ''
542-
543-
try {
544-
const _path = this._getFinalPath(path)
545-
const res = await get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
534+
const _path = this._getFinalPath(path)
535+
const downloadFn = () =>
536+
get(this.fetch, `${this.url}/${renderPath}/${_path}${queryString}`, {
546537
headers: this.headers,
547538
noResolveJson: true,
548539
})
549-
const data = await res.blob()
550-
return { data, error: null }
551-
} catch (error) {
552-
if (this.shouldThrowOnError) {
553-
throw error
554-
}
555-
if (isStorageError(error)) {
556-
return { data: null, error }
557-
}
558-
559-
throw error
560-
}
540+
return new BlobDownloadBuilder(downloadFn, this.shouldThrowOnError)
561541
}
562542

563543
/**
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { isStorageError } from '../lib/errors'
2+
import { DownloadResult } from '../lib/types'
3+
4+
export default class StreamDownloadBuilder implements PromiseLike<DownloadResult<ReadableStream>> {
5+
constructor(private downloadFn: () => Promise<Response>, private shouldThrowOnError: boolean) {}
6+
7+
then<TResult1 = DownloadResult<ReadableStream>, TResult2 = never>(
8+
onfulfilled?:
9+
| ((value: DownloadResult<ReadableStream>) => TResult1 | PromiseLike<TResult1>)
10+
| null,
11+
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
12+
): Promise<TResult1 | TResult2> {
13+
return this.execute().then(onfulfilled, onrejected)
14+
}
15+
16+
private async execute(): Promise<DownloadResult<ReadableStream>> {
17+
try {
18+
const result = await this.downloadFn()
19+
20+
return {
21+
data: result.body as ReadableStream,
22+
error: null,
23+
}
24+
} catch (error) {
25+
if (this.shouldThrowOnError) {
26+
throw error
27+
}
28+
29+
if (isStorageError(error)) {
30+
return { data: null, error }
31+
}
32+
33+
throw error
34+
}
35+
}
36+
}

test/storageFileApi.test.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import * as fsp from 'fs/promises'
33
import * as fs from 'fs'
44
import * as path from 'path'
55
import assert from 'assert'
6+
import ReadableStream from 'node:stream'
67
// @ts-ignore
78
import fetch, { Response } from '@supabase/node-fetch'
89
import { StorageApiError, StorageError } from '../src/lib/errors'
10+
import BlobDownloadBuilder from '../src/packages/BlobDownloadBuilder'
11+
import StreamDownloadBuilder from '../src/packages/StreamDownloadBuilder'
912

1013
// TODO: need to setup storage-api server for this test
1114
const URL = 'http://localhost:8000/storage/v1'
@@ -408,18 +411,37 @@ describe('Object API', () => {
408411

409412
test('downloads an object', async () => {
410413
await storage.from(bucketName).upload(uploadPath, file)
411-
const res = await storage.from(bucketName).download(uploadPath)
412414

413-
expect(res.error).toBeNull()
414-
expect(res.data?.size).toBeGreaterThan(0)
415-
expect(res.data?.type).toEqual('text/plain;charset=utf-8')
415+
const blobBuilder = storage.from(bucketName).download(uploadPath)
416+
expect(blobBuilder).toBeInstanceOf(BlobDownloadBuilder)
417+
418+
const blobResponse = await blobBuilder
419+
expect(blobResponse.error).toBeNull()
420+
expect(blobResponse.data?.size).toBeGreaterThan(0)
421+
expect(blobResponse.data?.type).toEqual('text/plain;charset=utf-8')
416422

417423
// throws when .throwOnError is enabled
418424
await expect(
419425
storage.from(bucketName).throwOnError().download('non-existent-file')
420426
).rejects.toThrow()
421427
})
422428

429+
test('downloads an object as a stream', async () => {
430+
await storage.from(bucketName).upload(uploadPath, file)
431+
432+
const streamBuilder = storage.from(bucketName).download(uploadPath).asStream()
433+
expect(streamBuilder).toBeInstanceOf(StreamDownloadBuilder)
434+
435+
const streamResponse = await streamBuilder
436+
expect(streamResponse.error).toBeNull()
437+
expect(streamResponse.data).toBeInstanceOf(ReadableStream)
438+
439+
// throws when .throwOnError is enabled
440+
await expect(
441+
storage.from(bucketName).throwOnError().download('non-existent-file').asStream()
442+
).rejects.toThrow()
443+
})
444+
423445
test('removes an object', async () => {
424446
await storage.from(bucketName).upload(uploadPath, file)
425447
const res = await storage.from(bucketName).remove([uploadPath])

test/storageFileApiErrorHandling.test.ts

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,15 @@ describe('File API Error Handling', () => {
2525
global.fetch = jest.fn().mockImplementation(() => Promise.reject(mockError))
2626
const storage = new StorageClient(URL, { apikey: KEY })
2727

28-
const { data, error } = await storage.from(BUCKET_ID).download('test.jpg')
29-
expect(data).toBeNull()
30-
expect(error).not.toBeNull()
31-
expect(error?.message).toBe('Network failure')
28+
const blobDownload = await storage.from(BUCKET_ID).download('test.jpg')
29+
expect(blobDownload.data).toBeNull()
30+
expect(blobDownload.error).not.toBeNull()
31+
expect(blobDownload.error?.message).toBe('Network failure')
32+
33+
const streamDownload = await storage.from(BUCKET_ID).download('test.jpg').asStream()
34+
expect(streamDownload.data).toBeNull()
35+
expect(streamDownload.error).not.toBeNull()
36+
expect(streamDownload.error?.message).toBe('Network failure')
3237
})
3338

3439
it('wraps non-Response errors as StorageUnknownError', async () => {
@@ -37,18 +42,23 @@ describe('File API Error Handling', () => {
3742

3843
const storage = new StorageClient(URL, { apikey: KEY })
3944

40-
const { data, error } = await storage.from(BUCKET_ID).download('test.jpg')
41-
expect(data).toBeNull()
42-
expect(error).toBeInstanceOf(StorageUnknownError)
43-
expect(error?.message).toBe('Invalid download format')
45+
const blobDownload = await storage.from(BUCKET_ID).download('test.jpg')
46+
expect(blobDownload.data).toBeNull()
47+
expect(blobDownload.error).toBeInstanceOf(StorageUnknownError)
48+
expect(blobDownload.error?.message).toBe('Invalid download format')
49+
50+
const streamDownload = await storage.from(BUCKET_ID).download('test.jpg').asStream()
51+
expect(streamDownload.data).toBeNull()
52+
expect(streamDownload.error).toBeInstanceOf(StorageUnknownError)
53+
expect(streamDownload.error?.message).toBe('Invalid download format')
4454
})
4555

4656
it('throws non-StorageError exceptions', async () => {
4757
// Create a storage client
4858
const storage = new StorageClient(URL, { apikey: KEY })
4959

5060
// Create a spy on the fetch method that will throw a non-StorageError
51-
const mockFn = jest.spyOn(global, 'fetch').mockImplementationOnce(() => {
61+
const mockFn = jest.spyOn(global, 'fetch').mockImplementation(() => {
5262
const error = new Error('Unexpected error in download')
5363
Object.defineProperty(error, 'name', { value: 'CustomError' })
5464
throw error
@@ -58,6 +68,10 @@ describe('File API Error Handling', () => {
5868
'Unexpected error in download'
5969
)
6070

71+
await expect(storage.from(BUCKET_ID).download('test.jpg').asStream()).rejects.toThrow(
72+
'Unexpected error in download'
73+
)
74+
6175
mockFn.mockRestore()
6276
})
6377
})

0 commit comments

Comments
 (0)