Skip to content

Commit 1e938c5

Browse files
committed
feat: add getDeployStore method
1 parent 6ec752e commit 1e938c5

File tree

7 files changed

+199
-148
lines changed

7 files changed

+199
-148
lines changed

README.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,25 +126,23 @@ know whether an item is still relevant or safe to delete.
126126
But sometimes it's useful to have data pegged to a specific deploy, and shift to the platform the responsibility of
127127
managing that data — keep it as long as the deploy is around, and wipe it if the deploy is deleted.
128128

129-
You can opt-in to this behavior by supplying a `deployID` instead of a `name` to the `getStore` method.
129+
You can opt-in to this behavior by creating the store using the `getDeployStore` method.
130130

131131
```ts
132132
import { assert } from 'node:assert'
133133

134-
import { getStore } from '@netlify/blobs'
134+
import { getDeployStore } from '@netlify/blobs'
135135

136136
// Using API access
137-
const store1 = getStore({
137+
const store1 = getDeployStore({
138138
deployID: 'MY_DEPLOY_ID',
139139
token: 'MY_API_TOKEN',
140140
})
141141

142142
await store1.set('my-key', 'my value')
143143

144144
// Using environment-based configuration
145-
const store2 = getStore({
146-
deployID: 'MY_DEPLOY_ID',
147-
})
145+
const store2 = getDeployStore()
148146

149147
assert.equal(await store2.get('my-key'), 'my value')
150148
```

src/client.ts

Lines changed: 23 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,6 @@
1-
import { Buffer } from 'node:buffer'
2-
import { env } from 'node:process'
3-
41
import { fetchAndRetry } from './retry.ts'
52
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'
63

7-
/**
8-
* The name of the environment variable that holds the context in a Base64,
9-
* JSON-encoded object. If we ever need to change the encoding or the shape
10-
* of this object, we should bump the version and create a new variable, so
11-
* that the client knows how to consume the data and can advise the user to
12-
* update the client if needed.
13-
*/
14-
export const NETLIFY_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT'
15-
16-
export interface Context {
17-
apiURL?: string
18-
edgeURL?: string
19-
siteID?: string
20-
token?: string
21-
}
22-
23-
interface PopulatedContext extends Context {
24-
siteID: string
25-
token: string
26-
}
27-
284
interface MakeStoreRequestOptions {
295
body?: BlobInput | null
306
headers?: Record<string, string>
@@ -33,59 +9,45 @@ interface MakeStoreRequestOptions {
339
storeName: string
3410
}
3511

12+
export interface ClientOptions {
13+
apiURL?: string
14+
edgeURL?: string
15+
fetch?: Fetcher
16+
siteID: string
17+
token: string
18+
}
19+
3620
export class Client {
37-
private context?: Context
21+
private apiURL?: string
22+
private edgeURL?: string
3823
private fetch?: Fetcher
24+
private siteID: string
25+
private token: string
3926

40-
constructor(context?: Context, fetch?: Fetcher) {
41-
this.context = context ?? {}
27+
constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions) {
28+
this.apiURL = apiURL
29+
this.edgeURL = edgeURL
4230
this.fetch = fetch
43-
}
44-
45-
private static getEnvironmentContext() {
46-
if (!env[NETLIFY_CONTEXT_VARIABLE]) {
47-
return
48-
}
49-
50-
const data = Buffer.from(env[NETLIFY_CONTEXT_VARIABLE], 'base64').toString()
51-
52-
try {
53-
return JSON.parse(data) as Context
54-
} catch {
55-
// no-op
56-
}
57-
}
58-
59-
private getContext() {
60-
const context = {
61-
...Client.getEnvironmentContext(),
62-
...this.context,
63-
}
64-
65-
if (!context.siteID || !context.token) {
66-
throw new Error(`The blob store is unavailable because it's missing required configuration properties`)
67-
}
68-
69-
return context as PopulatedContext
31+
this.siteID = siteID
32+
this.token = token
7033
}
7134

7235
private async getFinalRequest(storeName: string, key: string, method: string) {
73-
const context = this.getContext()
7436
const encodedKey = encodeURIComponent(key)
7537

76-
if ('edgeURL' in context) {
38+
if (this.edgeURL) {
7739
return {
7840
headers: {
79-
authorization: `Bearer ${context.token}`,
41+
authorization: `Bearer ${this.token}`,
8042
},
81-
url: `${context.edgeURL}/${context.siteID}/${storeName}/${encodedKey}`,
43+
url: `${this.edgeURL}/${this.siteID}/${storeName}/${encodedKey}`,
8244
}
8345
}
8446

85-
const apiURL = `${context.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
86-
context.siteID
47+
const apiURL = `${this.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${
48+
this.siteID
8749
}/blobs/${encodedKey}?context=${storeName}`
88-
const headers = { authorization: `Bearer ${context.token}` }
50+
const headers = { authorization: `Bearer ${this.token}` }
8951
const fetch = this.fetch ?? globalThis.fetch
9052
const res = await fetch(apiURL, { headers, method })
9153

src/environment.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Buffer } from 'node:buffer'
2+
import { env } from 'node:process'
3+
4+
/**
5+
* The name of the environment variable that holds the context in a Base64,
6+
* JSON-encoded object. If we ever need to change the encoding or the shape
7+
* of this object, we should bump the version and create a new variable, so
8+
* that the client knows how to consume the data and can advise the user to
9+
* update the client if needed.
10+
*/
11+
const NETLIFY_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT'
12+
13+
/**
14+
* The context object that we expect in the environment.
15+
*/
16+
export interface EnvironmentContext {
17+
apiURL?: string
18+
deployID?: string
19+
edgeURL?: string
20+
siteID?: string
21+
token?: string
22+
}
23+
24+
export const getEnvironmentContext = (): EnvironmentContext => {
25+
if (!env[NETLIFY_CONTEXT_VARIABLE]) {
26+
return {}
27+
}
28+
29+
const data = Buffer.from(env[NETLIFY_CONTEXT_VARIABLE], 'base64').toString()
30+
31+
try {
32+
return JSON.parse(data) as EnvironmentContext
33+
} catch {
34+
// no-op
35+
}
36+
37+
return {}
38+
}
39+
40+
export class MissingBlobsEnvironmentError extends Error {
41+
constructor(requiredProperties: string[]) {
42+
super(
43+
`The environment has not been configured to use Netlify Blobs. To use it manually, supply the following properties when creating a store: ${requiredProperties.join(
44+
', ',
45+
)}`,
46+
)
47+
48+
this.name = 'MissingBlobsEnvironmentError'
49+
}
50+
}

src/main.test.ts

Lines changed: 57 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { describe, test, expect, beforeAll, afterEach } from 'vitest'
77
import { MockFetch } from '../test/mock_fetch.js'
88
import { streamToString } from '../test/util.js'
99

10-
import { getStore } from './main.js'
10+
import { MissingBlobsEnvironmentError } from './environment.js'
11+
import { getDeployStore, getStore } from './main.js'
1112

1213
beforeAll(async () => {
1314
if (semver.lt(nodeVersion, '18.0.0')) {
@@ -315,28 +316,6 @@ describe('get', () => {
315316
expect(mockStore.fulfilled).toBeTruthy()
316317
})
317318
})
318-
319-
test('Throws when the instance is missing required configuration properties', async () => {
320-
const { fetch } = new MockFetch()
321-
322-
globalThis.fetch = fetch
323-
324-
const blobs1 = getStore('production')
325-
326-
expect(async () => await blobs1.get(key)).rejects.toThrowError(
327-
`The blob store is unavailable because it's missing required configuration properties`,
328-
)
329-
330-
const blobs2 = getStore({
331-
name: 'production',
332-
token: apiToken,
333-
siteID: '',
334-
})
335-
336-
expect(async () => await blobs2.get(key)).rejects.toThrowError(
337-
`The blob store is unavailable because it's missing required configuration properties`,
338-
)
339-
})
340319
})
341320

342321
describe('set', () => {
@@ -584,28 +563,6 @@ describe('set', () => {
584563
expect(mockStore.fulfilled).toBeTruthy()
585564
})
586565
})
587-
588-
test('Throws when the instance is missing required configuration properties', async () => {
589-
const { fetch } = new MockFetch()
590-
591-
globalThis.fetch = fetch
592-
593-
const blobs1 = getStore('production')
594-
595-
expect(async () => await blobs1.set(key, value)).rejects.toThrowError(
596-
`The blob store is unavailable because it's missing required configuration properties`,
597-
)
598-
599-
const blobs2 = getStore({
600-
name: 'production',
601-
token: apiToken,
602-
siteID: '',
603-
})
604-
605-
expect(async () => await blobs2.set(key, value)).rejects.toThrowError(
606-
`The blob store is unavailable because it's missing required configuration properties`,
607-
)
608-
})
609566
})
610567

611568
describe('setJSON', () => {
@@ -802,28 +759,6 @@ describe('delete', () => {
802759
expect(mockStore.fulfilled).toBeTruthy()
803760
})
804761
})
805-
806-
test('Throws when the instance is missing required configuration properties', async () => {
807-
const { fetch } = new MockFetch()
808-
809-
globalThis.fetch = fetch
810-
811-
const blobs1 = getStore('production')
812-
813-
expect(async () => await blobs1.delete(key)).rejects.toThrowError(
814-
`The blob store is unavailable because it's missing required configuration properties`,
815-
)
816-
817-
const blobs2 = getStore({
818-
name: 'production',
819-
token: apiToken,
820-
siteID: '',
821-
})
822-
823-
expect(async () => await blobs2.delete(key)).rejects.toThrowError(
824-
`The blob store is unavailable because it's missing required configuration properties`,
825-
)
826-
})
827762
})
828763

829764
describe('Deploy scope', () => {
@@ -843,12 +778,12 @@ describe('Deploy scope', () => {
843778
.get({
844779
headers: { authorization: `Bearer ${mockToken}` },
845780
response: new Response(value),
846-
url: `${edgeURL}/${siteID}/${deployID}/${key}`,
781+
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
847782
})
848783
.get({
849784
headers: { authorization: `Bearer ${mockToken}` },
850785
response: new Response(value),
851-
url: `${edgeURL}/${siteID}/${deployID}/${key}`,
786+
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
852787
})
853788

854789
globalThis.fetch = mockStore.fetch
@@ -879,6 +814,42 @@ describe('Deploy scope', () => {
879814

880815
expect(mockStore.fulfilled).toBeTruthy()
881816
})
817+
818+
test('Returns a deploy-scoped store if the `getDeployStore` method is called', async () => {
819+
const mockToken = 'some-token'
820+
const mockStore = new MockFetch()
821+
.get({
822+
headers: { authorization: `Bearer ${mockToken}` },
823+
response: new Response(value),
824+
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
825+
})
826+
.get({
827+
headers: { authorization: `Bearer ${mockToken}` },
828+
response: new Response(value),
829+
url: `${edgeURL}/${siteID}/deploy:${deployID}/${key}`,
830+
})
831+
832+
globalThis.fetch = mockStore.fetch
833+
834+
const context = {
835+
deployID,
836+
edgeURL,
837+
siteID,
838+
token: mockToken,
839+
}
840+
841+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
842+
843+
const deployStore = getDeployStore()
844+
845+
const string = await deployStore.get(key)
846+
expect(string).toBe(value)
847+
848+
const stream = await deployStore.get(key, { type: 'stream' })
849+
expect(await streamToString(stream as unknown as NodeJS.ReadableStream)).toBe(value)
850+
851+
expect(mockStore.fulfilled).toBeTruthy()
852+
})
882853
})
883854

884855
describe('Custom `fetch`', () => {
@@ -909,3 +880,20 @@ describe('Custom `fetch`', () => {
909880
expect(mockStore.fulfilled).toBeTruthy()
910881
})
911882
})
883+
884+
describe(`getStore`, () => {
885+
test('Throws when the instance is missing required configuration properties', async () => {
886+
const { fetch } = new MockFetch()
887+
888+
globalThis.fetch = fetch
889+
890+
expect(() => getStore('production')).toThrowError(MissingBlobsEnvironmentError)
891+
expect(() =>
892+
getStore({
893+
name: 'production',
894+
token: apiToken,
895+
siteID: '',
896+
}),
897+
).toThrowError(MissingBlobsEnvironmentError)
898+
})
899+
})

src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
export { getStore } from './store.ts'
1+
export { getDeployStore, getStore } from './store.ts'

0 commit comments

Comments
 (0)