Skip to content

Commit 0327476

Browse files
feat: add getMetadata method (#90)
1 parent 64e24bb commit 0327476

File tree

8 files changed

+208
-38
lines changed

8 files changed

+208
-38
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,22 @@ if (fresh) {
233233
}
234234
```
235235

236+
### `getMetadata(key: string, { etag?: string, type?: string }): Promise<{ data: any, etag: string, metadata: object }>`
237+
238+
Retrieves any metadata associated with a given key and its
239+
[ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag).
240+
241+
If an object with the given key is not found, `null` is returned.
242+
243+
This method can be used to check whether a key exists without having to actually retrieve it and transfer a
244+
potentially-large blob.
245+
246+
```javascript
247+
const blob = await store.getMetadata('some-key')
248+
249+
console.log(blob.etag, blob.metadata)
250+
```
251+
236252
### `set(key: string, value: ArrayBuffer | Blob | ReadableStream | string, { metadata?: object }): Promise<void>`
237253

238254
Creates an object with the given key and value.

src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export class Client {
8484

8585
url.searchParams.set('context', storeName)
8686

87+
// If there is no key, we're dealing with the list endpoint, which is
88+
// implemented directly in the Netlify API.
8789
if (key === undefined) {
8890
return {
8991
headers: apiHeaders,
@@ -97,6 +99,14 @@ export class Client {
9799
apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata
98100
}
99101

102+
// HEAD requests are implemented directly in the Netlify API.
103+
if (method === HTTPMethod.HEAD) {
104+
return {
105+
headers: apiHeaders,
106+
url: url.toString(),
107+
}
108+
}
109+
100110
const res = await this.fetch(url.toString(), { headers: apiHeaders, method })
101111

102112
if (res.status !== 200) {

src/main.test.ts

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,120 @@ describe('get', () => {
346346
})
347347
})
348348

349+
describe('getMetadata', () => {
350+
describe('With API credentials', () => {
351+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
352+
const mockMetadata = {
353+
name: 'Netlify',
354+
cool: true,
355+
functions: ['edge', 'serverless'],
356+
}
357+
const headers = {
358+
etag: '123456789',
359+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
360+
}
361+
const mockStore = new MockFetch().head({
362+
headers: { authorization: `Bearer ${apiToken}` },
363+
response: new Response(null, { headers }),
364+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
365+
})
366+
367+
globalThis.fetch = mockStore.fetch
368+
369+
const blobs = getStore({
370+
name: 'production',
371+
token: apiToken,
372+
siteID,
373+
})
374+
375+
const entry = await blobs.getMetadata(key)
376+
expect(entry?.etag).toBe(headers.etag)
377+
expect(entry?.metadata).toEqual(mockMetadata)
378+
379+
expect(mockStore.fulfilled).toBeTruthy()
380+
})
381+
382+
test('Returns `null` when the API returns a 404', async () => {
383+
const mockStore = new MockFetch().head({
384+
headers: { authorization: `Bearer ${apiToken}` },
385+
response: new Response(null, { status: 404 }),
386+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
387+
})
388+
389+
globalThis.fetch = mockStore.fetch
390+
391+
const blobs = getStore({
392+
name: 'production',
393+
token: apiToken,
394+
siteID,
395+
})
396+
397+
expect(await blobs.getMetadata(key)).toBeNull()
398+
expect(mockStore.fulfilled).toBeTruthy()
399+
})
400+
401+
test('Throws when the metadata object cannot be parsed', async () => {
402+
const headers = {
403+
etag: '123456789',
404+
'x-amz-meta-user': `b64;${base64Encode(`{"name": "Netlify", "cool`)}`,
405+
}
406+
const mockStore = new MockFetch().head({
407+
headers: { authorization: `Bearer ${apiToken}` },
408+
response: new Response(null, { headers }),
409+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
410+
})
411+
412+
globalThis.fetch = mockStore.fetch
413+
414+
const blobs = getStore({
415+
name: 'production',
416+
token: apiToken,
417+
siteID,
418+
})
419+
420+
await expect(async () => await blobs.getMetadata(key)).rejects.toThrowError(
421+
'An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client.',
422+
)
423+
424+
expect(mockStore.fulfilled).toBeTruthy()
425+
})
426+
})
427+
428+
describe('With edge credentials', () => {
429+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
430+
const mockMetadata = {
431+
name: 'Netlify',
432+
cool: true,
433+
functions: ['edge', 'serverless'],
434+
}
435+
const headers = {
436+
etag: '123456789',
437+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
438+
}
439+
const mockStore = new MockFetch().head({
440+
headers: { authorization: `Bearer ${edgeToken}` },
441+
response: new Response(null, { headers }),
442+
url: `${edgeURL}/${siteID}/production/${key}`,
443+
})
444+
445+
globalThis.fetch = mockStore.fetch
446+
447+
const blobs = getStore({
448+
edgeURL,
449+
name: 'production',
450+
token: edgeToken,
451+
siteID,
452+
})
453+
454+
const entry = await blobs.getMetadata(key)
455+
expect(entry?.etag).toBe(headers.etag)
456+
expect(entry?.metadata).toEqual(mockMetadata)
457+
458+
expect(mockStore.fulfilled).toBeTruthy()
459+
})
460+
})
461+
})
462+
349463
describe('getWithMetadata', () => {
350464
describe('With API credentials', () => {
351465
test('Reads from the blob store and returns the etag and the metadata object', async () => {
@@ -387,14 +501,14 @@ describe('getWithMetadata', () => {
387501
})
388502

389503
const entry1 = await blobs.getWithMetadata(key)
390-
expect(entry1.data).toBe(value)
391-
expect(entry1.etag).toBe(responseHeaders.etag)
392-
expect(entry1.metadata).toEqual(mockMetadata)
504+
expect(entry1?.data).toBe(value)
505+
expect(entry1?.etag).toBe(responseHeaders.etag)
506+
expect(entry1?.metadata).toEqual(mockMetadata)
393507

394508
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
395-
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
396-
expect(entry2.etag).toBe(responseHeaders.etag)
397-
expect(entry2.metadata).toEqual(mockMetadata)
509+
expect(await streamToString(entry2?.data as unknown as NodeJS.ReadableStream)).toBe(value)
510+
expect(entry2?.etag).toBe(responseHeaders.etag)
511+
expect(entry2?.metadata).toEqual(mockMetadata)
398512

399513
expect(mockStore.fulfilled).toBeTruthy()
400514
})
@@ -495,16 +609,16 @@ describe('getWithMetadata', () => {
495609
})
496610

497611
const staleEntry = await blobs.getWithMetadata(key, { etag: etags[0] })
498-
expect(staleEntry.data).toBe(value)
499-
expect(staleEntry.etag).toBe(etags[0])
500-
expect(staleEntry.fresh).toBe(false)
501-
expect(staleEntry.metadata).toEqual(mockMetadata)
612+
expect(staleEntry?.data).toBe(value)
613+
expect(staleEntry?.etag).toBe(etags[0])
614+
expect(staleEntry?.fresh).toBe(false)
615+
expect(staleEntry?.metadata).toEqual(mockMetadata)
502616

503617
const freshEntry = await blobs.getWithMetadata(key, { etag: etags[1], type: 'text' })
504-
expect(freshEntry.data).toBe(null)
505-
expect(freshEntry.etag).toBe(etags[0])
506-
expect(freshEntry.fresh).toBe(true)
507-
expect(freshEntry.metadata).toEqual(mockMetadata)
618+
expect(freshEntry?.data).toBe(null)
619+
expect(freshEntry?.etag).toBe(etags[0])
620+
expect(freshEntry?.fresh).toBe(true)
621+
expect(freshEntry?.metadata).toEqual(mockMetadata)
508622

509623
expect(mockStore.fulfilled).toBeTruthy()
510624
})
@@ -543,14 +657,14 @@ describe('getWithMetadata', () => {
543657
})
544658

545659
const entry1 = await blobs.getWithMetadata(key)
546-
expect(entry1.data).toBe(value)
547-
expect(entry1.etag).toBe(responseHeaders.etag)
548-
expect(entry1.metadata).toEqual(mockMetadata)
660+
expect(entry1?.data).toBe(value)
661+
expect(entry1?.etag).toBe(responseHeaders.etag)
662+
expect(entry1?.metadata).toEqual(mockMetadata)
549663

550664
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
551-
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
552-
expect(entry2.etag).toBe(responseHeaders.etag)
553-
expect(entry2.metadata).toEqual(mockMetadata)
665+
expect(await streamToString(entry2?.data as unknown as NodeJS.ReadableStream)).toBe(value)
666+
expect(entry2?.etag).toBe(responseHeaders.etag)
667+
expect(entry2?.metadata).toEqual(mockMetadata)
554668

555669
expect(mockStore.fulfilled).toBeTruthy()
556670
})

src/metadata.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,17 @@ export const decodeMetadata = (header: string | null): Metadata => {
3333

3434
return metadata
3535
}
36+
37+
export const getMetadataFromResponse = (response: Response) => {
38+
if (!response.headers) {
39+
return {}
40+
}
41+
42+
try {
43+
return decodeMetadata(response.headers.get(METADATA_HEADER_INTERNAL))
44+
} catch {
45+
throw new Error(
46+
'An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client.',
47+
)
48+
}
49+
}

src/server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ test('Reads and writes from the file system', async () => {
6262
expect(await blobs.get('parent')).toBe(null)
6363

6464
const entry = await blobs.getWithMetadata('simple-key')
65-
expect(entry.metadata).toEqual(metadata)
65+
expect(entry?.metadata).toEqual(metadata)
6666

6767
await blobs.delete('simple-key')
6868
expect(await blobs.get('simple-key')).toBe(null)

src/store.ts

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Buffer } from 'node:buffer'
22

33
import { ListResponse, ListResponseBlob } from './backend/list.ts'
44
import { Client } from './client.ts'
5-
import { decodeMetadata, Metadata, METADATA_HEADER_INTERNAL } from './metadata.ts'
5+
import { getMetadataFromResponse, Metadata } from './metadata.ts'
66
import { BlobInput, HTTPMethod } from './types.ts'
77
import { BlobsInternalError } from './util.ts'
88

@@ -131,10 +131,31 @@ export class Store {
131131
throw new BlobsInternalError(res.status)
132132
}
133133

134+
async getMetadata(key: string) {
135+
const res = await this.client.makeRequest({ key, method: HTTPMethod.HEAD, storeName: this.name })
136+
137+
if (res.status === 404) {
138+
return null
139+
}
140+
141+
if (res.status !== 200 && res.status !== 304) {
142+
throw new BlobsInternalError(res.status)
143+
}
144+
145+
const etag = res?.headers.get('etag') ?? undefined
146+
const metadata = getMetadataFromResponse(res)
147+
const result = {
148+
etag,
149+
metadata,
150+
}
151+
152+
return result
153+
}
154+
134155
async getWithMetadata(
135156
key: string,
136157
options?: GetWithMetadataOptions,
137-
): Promise<{ data: string } & GetWithMetadataResult>
158+
): Promise<({ data: string } & GetWithMetadataResult) | null>
138159

139160
async getWithMetadata(
140161
key: string,
@@ -144,26 +165,26 @@ export class Store {
144165
async getWithMetadata(
145166
key: string,
146167
options: { type: 'blob' } & GetWithMetadataOptions,
147-
): Promise<{ data: Blob } & GetWithMetadataResult>
168+
): Promise<({ data: Blob } & GetWithMetadataResult) | null>
148169

149170
/* eslint-disable @typescript-eslint/no-explicit-any */
150171

151172
async getWithMetadata(
152173
key: string,
153174
options: { type: 'json' } & GetWithMetadataOptions,
154-
): Promise<{ data: any } & GetWithMetadataResult>
175+
): Promise<({ data: any } & GetWithMetadataResult) | null>
155176

156177
/* eslint-enable @typescript-eslint/no-explicit-any */
157178

158179
async getWithMetadata(
159180
key: string,
160181
options: { type: 'stream' } & GetWithMetadataOptions,
161-
): Promise<{ data: ReadableStream } & GetWithMetadataResult>
182+
): Promise<({ data: ReadableStream } & GetWithMetadataResult) | null>
162183

163184
async getWithMetadata(
164185
key: string,
165186
options: { type: 'text' } & GetWithMetadataOptions,
166-
): Promise<{ data: string } & GetWithMetadataResult>
187+
): Promise<({ data: string } & GetWithMetadataResult) | null>
167188

168189
async getWithMetadata(
169190
key: string,
@@ -187,17 +208,7 @@ export class Store {
187208
}
188209

189210
const responseETag = res?.headers.get('etag') ?? undefined
190-
191-
let metadata: Metadata = {}
192-
193-
try {
194-
metadata = decodeMetadata(res?.headers.get(METADATA_HEADER_INTERNAL))
195-
} catch {
196-
throw new Error(
197-
'An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client.',
198-
)
199-
}
200-
211+
const metadata = getMetadataFromResponse(res)
201212
const result: GetWithMetadataResult = {
202213
etag: responseETag,
203214
fresh: false,

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export type Fetcher = typeof globalThis.fetch
55
export enum HTTPMethod {
66
DELETE = 'delete',
77
GET = 'get',
8+
HEAD = 'head',
89
PUT = 'put',
910
}

test/mock_fetch.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,10 @@ export class MockFetch {
4545
return this.addExpectedRequest({ ...options, method: 'get' })
4646
}
4747

48+
head(options: ExpectedRequestOptions) {
49+
return this.addExpectedRequest({ ...options, method: 'head' })
50+
}
51+
4852
post(options: ExpectedRequestOptions) {
4953
return this.addExpectedRequest({ ...options, method: 'post' })
5054
}

0 commit comments

Comments
 (0)