Skip to content

Commit 9403217

Browse files
committed
feat: add support for metadata
1 parent e2bdfff commit 9403217

File tree

6 files changed

+349
-100
lines changed

6 files changed

+349
-100
lines changed

README.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ console.log(await store.get('my-key'))
169169

170170
## Store API reference
171171

172-
### `get(key: string, { type: string }): Promise<any>`
172+
### `get(key: string, { type?: string }): Promise<any>`
173173

174174
Retrieves an object with the given key.
175175

@@ -191,6 +191,29 @@ const entry = await blobs.get('some-key', { type: 'json' })
191191
console.log(entry)
192192
```
193193

194+
### `getWithMetadata(key: string, { type?: string }): Promise<{ data: any, etag: string, metadata: object }>`
195+
196+
Retrieves an object with the given key, the [ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
197+
for the entry, and any metadata that has been stored with the entry.
198+
199+
Depending on the most convenient format for you to access the value, you may choose to supply a `type` property as a
200+
second parameter, with one of the following values:
201+
202+
- `arrayBuffer`: Returns the entry as an
203+
[`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer)
204+
- `blob`: Returns the entry as a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
205+
- `json`: Parses the entry as JSON and returns the resulting object
206+
- `stream`: Returns the entry as a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream)
207+
- `text` (default): Returns the entry as a string of plain text
208+
209+
If an object with the given key is not found, `null` is returned.
210+
211+
```javascript
212+
const blob = await blobs.getWithMetadata('some-key', { type: 'json' })
213+
214+
console.log(blob.data, blob.etag, blob.metadata)
215+
```
216+
194217
### `set(key: string, value: ArrayBuffer | Blob | ReadableStream | string): Promise<void>`
195218

196219
Creates an object with the given key and value.

src/client.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
2+
import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL } from './metadata.ts'
23
import { fetchAndRetry } from './retry.ts'
34
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'
45

56
interface MakeStoreRequestOptions {
67
body?: BlobInput | null
78
headers?: Record<string, string>
89
key: string
10+
metadata?: Metadata
911
method: HTTPMethod
1012
storeName: string
1113
}
@@ -33,21 +35,32 @@ export class Client {
3335
this.token = token
3436
}
3537

36-
private async getFinalRequest(storeName: string, key: string, method: string) {
38+
private async getFinalRequest(storeName: string, key: string, method: string, metadata?: Metadata) {
3739
const encodedKey = encodeURIComponent(key)
3840

3941
if (this.edgeURL) {
42+
const headers: Record<string, string> = {
43+
authorization: `Bearer ${this.token}`,
44+
}
45+
46+
if (metadata) {
47+
headers[METADATA_HEADER_EXTERNAL] = encodeMetadata(metadata)
48+
}
49+
4050
return {
41-
headers: {
42-
authorization: `Bearer ${this.token}`,
43-
},
51+
headers,
4452
url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`,
4553
}
4654
}
4755

48-
const apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
56+
let apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
4957
this.siteID
5058
}/blobs/${encodedKey}?context=${storeName}`
59+
60+
if (metadata) {
61+
apiURL += `&metadata=${encodeMetadata(metadata)}`
62+
}
63+
5164
const headers = { authorization: `Bearer ${this.token}` }
5265
const fetch = this.fetch ?? globalThis.fetch
5366
const res = await fetch(apiURL, { headers, method })
@@ -63,8 +76,8 @@ export class Client {
6376
}
6477
}
6578

66-
async makeRequest({ body, headers: extraHeaders, key, method, storeName }: MakeStoreRequestOptions) {
67-
const { headers: baseHeaders = {}, url } = await this.getFinalRequest(storeName, key, method)
79+
async makeRequest({ body, headers: extraHeaders, key, metadata, method, storeName }: MakeStoreRequestOptions) {
80+
const { headers: baseHeaders = {}, url } = await this.getFinalRequest(storeName, key, method, metadata)
6881
const headers: Record<string, string> = {
6982
...baseHeaders,
7083
...extraHeaders,

src/main.test.ts

Lines changed: 186 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import semver from 'semver'
55
import { describe, test, expect, beforeAll, afterEach } from 'vitest'
66

77
import { MockFetch } from '../test/mock_fetch.js'
8-
import { streamToString } from '../test/util.js'
8+
import { base64Encode, streamToString } from '../test/util.js'
99

1010
import { MissingBlobsEnvironmentError } from './environment.js'
1111
import { getDeployStore, getStore } from './main.js'
@@ -164,34 +164,6 @@ describe('get', () => {
164164

165165
expect(mockStore.fulfilled).toBeTruthy()
166166
})
167-
168-
test('Returns `null` when the blob entry contains an expiry date in the past', async () => {
169-
const mockStore = new MockFetch()
170-
.get({
171-
headers: { authorization: `Bearer ${apiToken}` },
172-
response: new Response(JSON.stringify({ url: signedURL })),
173-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
174-
})
175-
.get({
176-
response: new Response(value, {
177-
headers: {
178-
'x-nf-expires-at': (Date.now() - 1000).toString(),
179-
},
180-
}),
181-
url: signedURL,
182-
})
183-
184-
globalThis.fetch = mockStore.fetch
185-
186-
const blobs = getStore({
187-
name: 'production',
188-
token: apiToken,
189-
siteID,
190-
})
191-
192-
expect(await blobs.get(key)).toBeNull()
193-
expect(mockStore.fulfilled).toBeTruthy()
194-
})
195167
})
196168

197169
describe('With edge credentials', () => {
@@ -318,6 +290,162 @@ describe('get', () => {
318290
})
319291
})
320292

293+
describe('getWithMetadata', () => {
294+
describe('With API credentials', () => {
295+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
296+
const mockMetadata = {
297+
name: 'Netlify',
298+
cool: true,
299+
functions: ['edge', 'serverless'],
300+
}
301+
const responseHeaders = {
302+
etag: '123456789',
303+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
304+
}
305+
const mockStore = new MockFetch()
306+
.get({
307+
headers: { authorization: `Bearer ${apiToken}` },
308+
response: new Response(JSON.stringify({ url: signedURL })),
309+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
310+
})
311+
.get({
312+
response: new Response(value, { headers: responseHeaders }),
313+
url: signedURL,
314+
})
315+
.get({
316+
headers: { authorization: `Bearer ${apiToken}` },
317+
response: new Response(JSON.stringify({ url: signedURL })),
318+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
319+
})
320+
.get({
321+
response: new Response(value, { headers: responseHeaders }),
322+
url: signedURL,
323+
})
324+
325+
globalThis.fetch = mockStore.fetch
326+
327+
const blobs = getStore({
328+
name: 'production',
329+
token: apiToken,
330+
siteID,
331+
})
332+
333+
const entry1 = await blobs.getWithMetadata(key)
334+
expect(entry1.data).toBe(value)
335+
expect(entry1.etag).toBe(responseHeaders.etag)
336+
expect(entry1.metadata).toEqual(mockMetadata)
337+
338+
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
339+
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
340+
expect(entry2.etag).toBe(responseHeaders.etag)
341+
expect(entry2.metadata).toEqual(mockMetadata)
342+
343+
expect(mockStore.fulfilled).toBeTruthy()
344+
})
345+
346+
test('Returns `null` when the pre-signed URL returns a 404', async () => {
347+
const mockStore = new MockFetch()
348+
.get({
349+
headers: { authorization: `Bearer ${apiToken}` },
350+
response: new Response(JSON.stringify({ url: signedURL })),
351+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
352+
})
353+
.get({
354+
response: new Response('Something went wrong', { status: 404 }),
355+
url: signedURL,
356+
})
357+
358+
globalThis.fetch = mockStore.fetch
359+
360+
const blobs = getStore({
361+
name: 'production',
362+
token: apiToken,
363+
siteID,
364+
})
365+
366+
expect(await blobs.getWithMetadata(key)).toBeNull()
367+
expect(mockStore.fulfilled).toBeTruthy()
368+
})
369+
370+
test('Throws when the metadata object cannot be parsed', async () => {
371+
const responseHeaders = {
372+
etag: '123456789',
373+
'x-amz-meta-user': `b64;${base64Encode(`{"name": "Netlify", "cool`)}`,
374+
}
375+
const mockStore = new MockFetch()
376+
.get({
377+
headers: { authorization: `Bearer ${apiToken}` },
378+
response: new Response(JSON.stringify({ url: signedURL })),
379+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
380+
})
381+
.get({
382+
response: new Response(value, { headers: responseHeaders }),
383+
url: signedURL,
384+
})
385+
386+
globalThis.fetch = mockStore.fetch
387+
388+
const blobs = getStore({
389+
name: 'production',
390+
token: apiToken,
391+
siteID,
392+
})
393+
394+
await expect(async () => await blobs.getWithMetadata(key)).rejects.toThrowError(
395+
'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.',
396+
)
397+
398+
expect(mockStore.fulfilled).toBeTruthy()
399+
})
400+
})
401+
402+
describe('With edge credentials', () => {
403+
test('Reads from the blob store and returns the etag and the metadata object', async () => {
404+
const mockMetadata = {
405+
name: 'Netlify',
406+
cool: true,
407+
functions: ['edge', 'serverless'],
408+
}
409+
const responseHeaders = {
410+
etag: '123456789',
411+
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
412+
}
413+
const mockStore = new MockFetch()
414+
.get({
415+
headers: { authorization: `Bearer ${edgeToken}` },
416+
response: new Response(value, { headers: responseHeaders }),
417+
url: `${edgeURL}/${siteID}/production/${key}`,
418+
})
419+
.get({
420+
headers: { authorization: `Bearer ${edgeToken}` },
421+
response: new Response(value, { headers: responseHeaders }),
422+
url: `${edgeURL}/${siteID}/production/${key}`,
423+
})
424+
425+
globalThis.fetch = mockStore.fetch
426+
427+
const blobs = getStore({
428+
edgeURL,
429+
name: 'production',
430+
token: edgeToken,
431+
siteID,
432+
})
433+
434+
const entry1 = await blobs.getWithMetadata(key)
435+
expect(entry1.data).toBe(value)
436+
expect(entry1.etag).toBe(responseHeaders.etag)
437+
expect(entry1.metadata).toEqual(mockMetadata)
438+
439+
const entry2 = await blobs.getWithMetadata(key, { type: 'stream' })
440+
expect(await streamToString(entry2.data as unknown as NodeJS.ReadableStream)).toBe(value)
441+
expect(entry2.etag).toBe(responseHeaders.etag)
442+
expect(entry2.metadata).toEqual(mockMetadata)
443+
444+
expect(mockStore.fulfilled).toBeTruthy()
445+
})
446+
})
447+
})
448+
321449
describe('set', () => {
322450
describe('With API credentials', () => {
323451
test('Writes to the blob store', async () => {
@@ -361,19 +489,23 @@ describe('set', () => {
361489
expect(mockStore.fulfilled).toBeTruthy()
362490
})
363491

364-
test('Accepts an `expiration` parameter', async () => {
365-
const expiration = new Date(Date.now() + 15_000)
492+
test('Accepts a `metadata` parameter', async () => {
493+
const metadata = {
494+
name: 'Netlify',
495+
cool: true,
496+
functions: ['edge', 'serverless'],
497+
}
498+
const encodedMetadata = `b64;${Buffer.from(JSON.stringify(metadata)).toString('base64')}`
366499
const mockStore = new MockFetch()
367500
.put({
368501
headers: { authorization: `Bearer ${apiToken}` },
369502
response: new Response(JSON.stringify({ url: signedURL })),
370-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
503+
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production&metadata=${encodedMetadata}`,
371504
})
372505
.put({
373506
body: value,
374507
headers: {
375508
'cache-control': 'max-age=0, stale-while-revalidate=60',
376-
'x-nf-expires-at': expiration.getTime().toString(),
377509
},
378510
response: new Response(null),
379511
url: signedURL,
@@ -387,7 +519,7 @@ describe('set', () => {
387519
siteID,
388520
})
389521

390-
await blobs.set(key, value, { expiration })
522+
await blobs.set(key, value, { metadata })
391523

392524
expect(mockStore.fulfilled).toBeTruthy()
393525
})
@@ -620,33 +752,34 @@ describe('setJSON', () => {
620752
expect(mockStore.fulfilled).toBeTruthy()
621753
})
622754

623-
test('Accepts an `expiration` parameter', async () => {
624-
const expiration = new Date(Date.now() + 15_000)
625-
const mockStore = new MockFetch()
626-
.put({
627-
headers: { authorization: `Bearer ${apiToken}` },
628-
response: new Response(JSON.stringify({ url: signedURL })),
629-
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
630-
})
631-
.put({
632-
body: JSON.stringify({ value }),
633-
headers: {
634-
'cache-control': 'max-age=0, stale-while-revalidate=60',
635-
'x-nf-expires-at': expiration.getTime().toString(),
636-
},
637-
response: new Response(null),
638-
url: signedURL,
639-
})
755+
test('Accepts a `metadata` parameter', async () => {
756+
const metadata = {
757+
name: 'Netlify',
758+
cool: true,
759+
functions: ['edge', 'serverless'],
760+
}
761+
const encodedMetadata = `b64;${Buffer.from(JSON.stringify(metadata)).toString('base64')}`
762+
const mockStore = new MockFetch().put({
763+
body: JSON.stringify({ value }),
764+
headers: {
765+
authorization: `Bearer ${edgeToken}`,
766+
'cache-control': 'max-age=0, stale-while-revalidate=60',
767+
'netlify-blobs-metadata': encodedMetadata,
768+
},
769+
response: new Response(null),
770+
url: `${edgeURL}/${siteID}/production/${key}`,
771+
})
640772

641773
globalThis.fetch = mockStore.fetch
642774

643775
const blobs = getStore({
776+
edgeURL,
644777
name: 'production',
645-
token: apiToken,
778+
token: edgeToken,
646779
siteID,
647780
})
648781

649-
await blobs.setJSON(key, { value }, { expiration })
782+
await blobs.setJSON(key, { value }, { metadata })
650783

651784
expect(mockStore.fulfilled).toBeTruthy()
652785
})

0 commit comments

Comments
 (0)