Skip to content

Commit 716d679

Browse files
authored
feat: custom-metadata, exists, info methods (#207)
1 parent 03ad444 commit 716d679

File tree

6 files changed

+236
-10
lines changed

6 files changed

+236
-10
lines changed

infra/storage/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
FROM supabase/storage-api:v1.2.1
1+
FROM supabase/storage-api:v1.8.2
22

33
RUN apk add curl --no-cache

src/lib/fetch.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,19 @@ export interface FetchOptions {
1111
noResolveJson?: boolean
1212
}
1313

14-
export type RequestMethodType = 'GET' | 'POST' | 'PUT' | 'DELETE'
14+
export type RequestMethodType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'HEAD'
1515

1616
const _getErrorMessage = (err: any): string =>
1717
err.msg || err.message || err.error_description || err.error || JSON.stringify(err)
1818

19-
const handleError = async (error: unknown, reject: (reason?: any) => void) => {
19+
const handleError = async (
20+
error: unknown,
21+
reject: (reason?: any) => void,
22+
options?: FetchOptions
23+
) => {
2024
const Res = await resolveResponse()
2125

22-
if (error instanceof Res) {
26+
if (error instanceof Res && !options?.noResolveJson) {
2327
error
2428
.json()
2529
.then((err) => {
@@ -46,7 +50,10 @@ const _getRequestParams = (
4650
}
4751

4852
params.headers = { 'Content-Type': 'application/json', ...options?.headers }
49-
params.body = JSON.stringify(body)
53+
54+
if (body) {
55+
params.body = JSON.stringify(body)
56+
}
5057
return { ...params, ...parameters }
5158
}
5259

@@ -66,7 +73,7 @@ async function _handleRequest(
6673
return result.json()
6774
})
6875
.then((data) => resolve(data))
69-
.catch((error) => handleError(error, reject))
76+
.catch((error) => handleError(error, reject, options))
7077
})
7178
}
7279

@@ -99,6 +106,24 @@ export async function put(
99106
return _handleRequest(fetcher, 'PUT', url, options, parameters, body)
100107
}
101108

109+
export async function head(
110+
fetcher: Fetch,
111+
url: string,
112+
options?: FetchOptions,
113+
parameters?: FetchParameters
114+
): Promise<any> {
115+
return _handleRequest(
116+
fetcher,
117+
'HEAD',
118+
url,
119+
{
120+
...options,
121+
noResolveJson: true,
122+
},
123+
parameters
124+
)
125+
}
126+
102127
export async function remove(
103128
fetcher: Fetch,
104129
url: string,

src/lib/helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,19 @@ export const resolveResponse = async (): Promise<typeof Response> => {
2121

2222
return Response
2323
}
24+
25+
export const recursiveToCamel = (item: Record<string, any>): unknown => {
26+
if (Array.isArray(item)) {
27+
return item.map((el) => recursiveToCamel(el))
28+
} else if (typeof item === 'function' || item !== Object(item)) {
29+
return item
30+
}
31+
32+
const result: Record<string, any> = {}
33+
Object.entries(item).forEach(([key, value]) => {
34+
const newKey = key.replace(/([-_][a-z])/gi, (c) => c.toUpperCase().replace(/[-_]/g, ''))
35+
result[newKey] = recursiveToCamel(value)
36+
})
37+
38+
return result
39+
}

src/lib/types.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,22 @@ export interface FileObject {
2121
buckets: Bucket
2222
}
2323

24+
export interface FileObjectV2 {
25+
id: string
26+
version: string
27+
name: string
28+
bucket_id: string
29+
updated_at: string
30+
created_at: string
31+
last_accessed_at: string
32+
size?: number
33+
cache_control?: string
34+
content_type?: string
35+
etag?: string
36+
last_modified?: string
37+
metadata?: Record<string, any>
38+
}
39+
2440
export interface SortBy {
2541
column?: string
2642
order?: string
@@ -43,6 +59,16 @@ export interface FileOptions {
4359
* The duplex option is a string parameter that enables or disables duplex streaming, allowing for both reading and writing data in the same stream. It can be passed as an option to the fetch() method.
4460
*/
4561
duplex?: string
62+
63+
/**
64+
* The metadata option is an object that allows you to store additional information about the file. This information can be used to filter and search for files. The metadata object can contain any key-value pairs you want to store.
65+
*/
66+
metadata?: Record<string, any>
67+
68+
/**
69+
* Optionally add extra headers
70+
*/
71+
headers?: Record<string, string>
4672
}
4773

4874
export interface DestinationOptions {
@@ -113,3 +139,11 @@ export interface TransformOptions {
113139
*/
114140
format?: 'origin'
115141
}
142+
143+
type CamelCase<S extends string> = S extends `${infer P1}_${infer P2}${infer P3}`
144+
? `${Lowercase<P1>}${Uppercase<P2>}${CamelCase<P3>}`
145+
: S
146+
147+
export type Camelize<T> = {
148+
[K in keyof T as CamelCase<Extract<K, string>>]: T[K]
149+
}

src/packages/StorageFileApi.ts

Lines changed: 104 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { isStorageError, StorageError } from '../lib/errors'
2-
import { Fetch, get, post, remove } from '../lib/fetch'
3-
import { resolveFetch } from '../lib/helpers'
1+
import { isStorageError, StorageError, StorageUnknownError } from '../lib/errors'
2+
import { Fetch, get, head, post, remove } from '../lib/fetch'
3+
import { recursiveToCamel, resolveFetch } from '../lib/helpers'
44
import {
55
FileObject,
66
FileOptions,
77
SearchOptions,
88
FetchParameters,
99
TransformOptions,
1010
DestinationOptions,
11+
FileObjectV2,
12+
Camelize,
1113
} from '../lib/types'
1214

1315
const DEFAULT_SEARCH_OPTIONS = {
@@ -80,22 +82,39 @@ export default class StorageFileApi {
8082
try {
8183
let body
8284
const options = { ...DEFAULT_FILE_OPTIONS, ...fileOptions }
83-
const headers: Record<string, string> = {
85+
let headers: Record<string, string> = {
8486
...this.headers,
8587
...(method === 'POST' && { 'x-upsert': String(options.upsert as boolean) }),
8688
}
8789

90+
const metadata = options.metadata
91+
8892
if (typeof Blob !== 'undefined' && fileBody instanceof Blob) {
8993
body = new FormData()
9094
body.append('cacheControl', options.cacheControl as string)
9195
body.append('', fileBody)
96+
97+
if (metadata) {
98+
body.append('metadata', this.encodeMetadata(metadata))
99+
}
92100
} else if (typeof FormData !== 'undefined' && fileBody instanceof FormData) {
93101
body = fileBody
94102
body.append('cacheControl', options.cacheControl as string)
103+
if (metadata) {
104+
body.append('metadata', this.encodeMetadata(metadata))
105+
}
95106
} else {
96107
body = fileBody
97108
headers['cache-control'] = `max-age=${options.cacheControl}`
98109
headers['content-type'] = options.contentType as string
110+
111+
if (metadata) {
112+
headers['x-metadata'] = this.toBase64(this.encodeMetadata(metadata))
113+
}
114+
}
115+
116+
if (fileOptions?.headers) {
117+
headers = { ...headers, ...fileOptions.headers }
99118
}
100119

101120
const cleanPath = this._removeEmptyFolders(path)
@@ -525,6 +544,76 @@ export default class StorageFileApi {
525544
}
526545
}
527546

547+
/**
548+
* Retrieves the details of an existing file.
549+
* @param path
550+
*/
551+
async info(
552+
path: string
553+
): Promise<
554+
| {
555+
data: Camelize<FileObjectV2>
556+
error: null
557+
}
558+
| {
559+
data: null
560+
error: StorageError
561+
}
562+
> {
563+
const _path = this._getFinalPath(path)
564+
565+
try {
566+
const data = await get(this.fetch, `${this.url}/object/info/${_path}`, {
567+
headers: this.headers,
568+
})
569+
570+
return { data: recursiveToCamel(data) as Camelize<FileObjectV2>, error: null }
571+
} catch (error) {
572+
if (isStorageError(error)) {
573+
return { data: null, error }
574+
}
575+
576+
throw error
577+
}
578+
}
579+
580+
/**
581+
* Checks the existence of a file.
582+
* @param path
583+
*/
584+
async exists(
585+
path: string
586+
): Promise<
587+
| {
588+
data: boolean
589+
error: null
590+
}
591+
| {
592+
data: boolean
593+
error: StorageError
594+
}
595+
> {
596+
const _path = this._getFinalPath(path)
597+
598+
try {
599+
await head(this.fetch, `${this.url}/object/${_path}`, {
600+
headers: this.headers,
601+
})
602+
603+
return { data: true, error: null }
604+
} catch (error) {
605+
if (isStorageError(error) && error instanceof StorageUnknownError) {
606+
const originalError = (error.originalError as unknown) as { status: number }
607+
608+
if ([400, 404].includes(originalError?.status)) {
609+
return { data: false, error }
610+
}
611+
}
612+
613+
throw error
614+
}
615+
}
616+
528617
/**
529618
* A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset.
530619
* This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset.
@@ -700,6 +789,17 @@ export default class StorageFileApi {
700789
}
701790
}
702791

792+
protected encodeMetadata(metadata: Record<string, any>) {
793+
return JSON.stringify(metadata)
794+
}
795+
796+
toBase64(data: string) {
797+
if (typeof Buffer !== 'undefined') {
798+
return Buffer.from(data).toString('base64')
799+
}
800+
return btoa(data)
801+
}
802+
703803
private _getFinalPath(path: string) {
704804
return `${this.bucketId}/${path}`
705805
}

test/storageFileApi.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,25 @@ describe('Object API', () => {
163163
expect(updateRes.data?.path).toEqual(uploadPath)
164164
})
165165

166+
test('can upload with custom metadata', async () => {
167+
const res = await storage.from(bucketName).upload(uploadPath, file, {
168+
metadata: {
169+
custom: 'metadata',
170+
second: 'second',
171+
third: 'third',
172+
},
173+
})
174+
expect(res.error).toBeNull()
175+
176+
const updateRes = await storage.from(bucketName).info(uploadPath)
177+
expect(updateRes.error).toBeNull()
178+
expect(updateRes.data?.metadata).toEqual({
179+
custom: 'metadata',
180+
second: 'second',
181+
third: 'third',
182+
})
183+
})
184+
166185
test('can upload a file within the file size limit', async () => {
167186
const bucketName = 'with-limit' + Date.now()
168187
await storage.createBucket(bucketName, {
@@ -368,6 +387,38 @@ describe('Object API', () => {
368387
}),
369388
])
370389
})
390+
391+
test('get object info', async () => {
392+
await storage.from(bucketName).upload(uploadPath, file)
393+
const res = await storage.from(bucketName).info(uploadPath)
394+
395+
expect(res.error).toBeNull()
396+
expect(res.data).toEqual(
397+
expect.objectContaining({
398+
id: expect.any(String),
399+
name: expect.any(String),
400+
createdAt: expect.any(String),
401+
cacheControl: expect.any(String),
402+
size: expect.any(Number),
403+
etag: expect.any(String),
404+
lastModified: expect.any(String),
405+
contentType: expect.any(String),
406+
metadata: {},
407+
version: expect.any(String),
408+
})
409+
)
410+
})
411+
412+
test('check if object exists', async () => {
413+
await storage.from(bucketName).upload(uploadPath, file)
414+
const res = await storage.from(bucketName).exists(uploadPath)
415+
416+
expect(res.error).toBeNull()
417+
expect(res.data).toEqual(true)
418+
419+
const resNotExists = await storage.from(bucketName).exists('do-not-exists')
420+
expect(resNotExists.data).toEqual(false)
421+
})
371422
})
372423

373424
describe('Transformations', () => {

0 commit comments

Comments
 (0)