Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3d0a386

Browse files
authoredMar 4, 2024
feat!: add listStores method (#149)
1 parent 5ad27eb commit 3d0a386

File tree

11 files changed

+472
-113
lines changed

11 files changed

+472
-113
lines changed
 

‎.eslintrc.cjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module.exports = {
2323
'unicorn/prefer-ternary': 'off',
2424
'no-unused-vars': 'off',
2525
'@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
26+
'func-style': 'off',
2627
},
2728
overrides: [
2829
...overrides,

‎src/backend/list_stores.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ListStoresResponse {
2+
stores: string[]
3+
next_cursor?: string
4+
}

‎src/client.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ interface MakeStoreRequestOptions {
1212
metadata?: Metadata
1313
method: HTTPMethod
1414
parameters?: Record<string, string>
15-
storeName: string
15+
storeName?: string
1616
}
1717

1818
export interface ClientOptions {
@@ -31,7 +31,7 @@ interface GetFinalRequestOptions {
3131
metadata?: Metadata
3232
method: string
3333
parameters?: Record<string, string>
34-
storeName: string
34+
storeName?: string
3535
}
3636

3737
export class Client {
@@ -70,6 +70,16 @@ export class Client {
7070
const encodedMetadata = encodeMetadata(metadata)
7171
const consistency = opConsistency ?? this.consistency
7272

73+
let urlPath = `/${this.siteID}`
74+
75+
if (storeName) {
76+
urlPath += `/${storeName}`
77+
}
78+
79+
if (key) {
80+
urlPath += `/${key}`
81+
}
82+
7383
if (this.edgeURL) {
7484
if (consistency === 'strong' && !this.uncachedEdgeURL) {
7585
throw new BlobsConsistencyError()
@@ -83,8 +93,7 @@ export class Client {
8393
headers[METADATA_HEADER_INTERNAL] = encodedMetadata
8494
}
8595

86-
const path = key ? `/${this.siteID}/${storeName}/${key}` : `/${this.siteID}/${storeName}`
87-
const url = new URL(path, consistency === 'strong' ? this.uncachedEdgeURL : this.edgeURL)
96+
const url = new URL(urlPath, consistency === 'strong' ? this.uncachedEdgeURL : this.edgeURL)
8897

8998
for (const key in parameters) {
9099
url.searchParams.set(key, parameters[key])
@@ -97,23 +106,22 @@ export class Client {
97106
}
98107

99108
const apiHeaders: Record<string, string> = { authorization: `Bearer ${this.token}` }
100-
const url = new URL(`/api/v1/blobs/${this.siteID}/${storeName}`, this.apiURL ?? 'https://api.netlify.com')
109+
const url = new URL(`/api/v1/blobs${urlPath}`, this.apiURL ?? 'https://api.netlify.com')
101110

102111
for (const key in parameters) {
103112
url.searchParams.set(key, parameters[key])
104113
}
105114

106-
// If there is no key, we're dealing with the list endpoint, which is
107-
// implemented directly in the Netlify API.
108-
if (key === undefined) {
115+
// If there is no store name, we're listing stores. If there's no key,
116+
// we're listing blobs. Both operations are implemented directly in the
117+
// Netlify API.
118+
if (storeName === undefined || key === undefined) {
109119
return {
110120
headers: apiHeaders,
111121
url: url.toString(),
112122
}
113123
}
114124

115-
url.pathname += `/${key}`
116-
117125
if (encodedMetadata) {
118126
apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata
119127
}

‎src/consistency.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,17 @@ describe('Consistency configuration', () => {
5252
.get({
5353
headers: { authorization: `Bearer ${edgeToken}` },
5454
response: new Response(value),
55-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
55+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
5656
})
5757
.head({
5858
headers: { authorization: `Bearer ${edgeToken}` },
5959
response: new Response(null, { headers }),
60-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
60+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
6161
})
6262
.get({
6363
headers: { authorization: `Bearer ${edgeToken}` },
6464
response: new Response(value, { headers }),
65-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
65+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
6666
})
6767

6868
globalThis.fetch = mockStore.fetch
@@ -107,17 +107,17 @@ describe('Consistency configuration', () => {
107107
.get({
108108
headers: { authorization: `Bearer ${edgeToken}` },
109109
response: new Response(value),
110-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
110+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
111111
})
112112
.head({
113113
headers: { authorization: `Bearer ${edgeToken}` },
114114
response: new Response(null, { headers }),
115-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
115+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
116116
})
117117
.get({
118118
headers: { authorization: `Bearer ${edgeToken}` },
119119
response: new Response(value, { headers }),
120-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
120+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
121121
})
122122

123123
globalThis.fetch = mockStore.fetch
@@ -213,17 +213,17 @@ describe('Consistency configuration', () => {
213213
.get({
214214
headers: { authorization: `Bearer ${edgeToken}` },
215215
response: new Response(value),
216-
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
216+
url: `${uncachedEdgeURL}/${siteID}/site:production/${key}`,
217217
})
218218
.head({
219219
headers: { authorization: `Bearer ${edgeToken}` },
220220
response: new Response(null, { headers }),
221-
url: `${edgeURL}/${siteID}/production/${key}`,
221+
url: `${edgeURL}/${siteID}/site:production/${key}`,
222222
})
223223
.get({
224224
headers: { authorization: `Bearer ${edgeToken}` },
225225
response: new Response(value, { headers }),
226-
url: `${edgeURL}/${siteID}/production/${key}`,
226+
url: `${edgeURL}/${siteID}/site:production/${key}`,
227227
})
228228

229229
globalThis.fetch = mockStore.fetch

‎src/lambda_compat.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,12 @@ describe('With edge credentials', () => {
4848
.get({
4949
headers: { authorization: `Bearer ${edgeToken}` },
5050
response: new Response(value),
51-
url: `${edgeURL}/${siteID}/production/${key}`,
51+
url: `${edgeURL}/${siteID}/site:production/${key}`,
5252
})
5353
.get({
5454
headers: { authorization: `Bearer ${edgeToken}` },
5555
response: new Response(value),
56-
url: `${edgeURL}/${siteID}/production/${key}`,
56+
url: `${edgeURL}/${siteID}/site:production/${key}`,
5757
})
5858

5959
globalThis.fetch = mockStore.fetch

‎src/list.test.ts

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('list', () => {
5959
next_cursor: 'cursor_1',
6060
}),
6161
),
62-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}`,
62+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}`,
6363
})
6464
.get({
6565
headers: { authorization: `Bearer ${apiToken}` },
@@ -83,7 +83,7 @@ describe('list', () => {
8383
next_cursor: 'cursor_2',
8484
}),
8585
),
86-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?cursor=cursor_1`,
86+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?cursor=cursor_1`,
8787
})
8888
.get({
8989
headers: { authorization: `Bearer ${apiToken}` },
@@ -100,7 +100,7 @@ describe('list', () => {
100100
directories: [],
101101
}),
102102
),
103-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?cursor=cursor_2`,
103+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?cursor=cursor_2`,
104104
})
105105

106106
globalThis.fetch = mockStore.fetch
@@ -148,7 +148,7 @@ describe('list', () => {
148148
next_cursor: 'cursor_1',
149149
}),
150150
),
151-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?directories=true`,
151+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?directories=true`,
152152
})
153153
.get({
154154
headers: { authorization: `Bearer ${apiToken}` },
@@ -172,7 +172,7 @@ describe('list', () => {
172172
next_cursor: 'cursor_2',
173173
}),
174174
),
175-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?directories=true&cursor=cursor_1`,
175+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?directories=true&cursor=cursor_1`,
176176
})
177177
.get({
178178
headers: { authorization: `Bearer ${apiToken}` },
@@ -189,7 +189,7 @@ describe('list', () => {
189189
directories: ['dir3'],
190190
}),
191191
),
192-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?directories=true&cursor=cursor_2`,
192+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?directories=true&cursor=cursor_2`,
193193
})
194194
.get({
195195
headers: { authorization: `Bearer ${apiToken}` },
@@ -206,7 +206,7 @@ describe('list', () => {
206206
directories: [],
207207
}),
208208
),
209-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?prefix=dir2%2F&directories=true`,
209+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?prefix=dir2%2F&directories=true`,
210210
})
211211

212212
globalThis.fetch = mockStore.fetch
@@ -258,7 +258,7 @@ describe('list', () => {
258258
],
259259
}),
260260
),
261-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?prefix=group%2F`,
261+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?prefix=group%2F`,
262262
})
263263

264264
globalThis.fetch = mockStore.fetch
@@ -303,7 +303,7 @@ describe('list', () => {
303303
next_cursor: 'cursor_2',
304304
}),
305305
),
306-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}`,
306+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}`,
307307
})
308308
.get({
309309
headers: { authorization: `Bearer ${apiToken}` },
@@ -319,7 +319,7 @@ describe('list', () => {
319319
],
320320
}),
321321
),
322-
url: `https://api.netlify.com/api/v1/blobs/${siteID}/${storeName}?cursor=cursor_2`,
322+
url: `https://api.netlify.com/api/v1/blobs/${siteID}/site:${storeName}?cursor=cursor_2`,
323323
})
324324

325325
globalThis.fetch = mockStore.fetch
@@ -373,7 +373,7 @@ describe('list', () => {
373373
next_cursor: 'cursor_1',
374374
}),
375375
),
376-
url: `${edgeURL}/${siteID}/${storeName}`,
376+
url: `${edgeURL}/${siteID}/site:${storeName}`,
377377
})
378378
.get({
379379
headers: { authorization: `Bearer ${edgeToken}` },
@@ -397,7 +397,7 @@ describe('list', () => {
397397
next_cursor: 'cursor_2',
398398
}),
399399
),
400-
url: `${edgeURL}/${siteID}/${storeName}?cursor=cursor_1`,
400+
url: `${edgeURL}/${siteID}/site:${storeName}?cursor=cursor_1`,
401401
})
402402
.get({
403403
headers: { authorization: `Bearer ${edgeToken}` },
@@ -414,7 +414,7 @@ describe('list', () => {
414414
directories: [],
415415
}),
416416
),
417-
url: `${edgeURL}/${siteID}/${storeName}?cursor=cursor_2`,
417+
url: `${edgeURL}/${siteID}/site:${storeName}?cursor=cursor_2`,
418418
})
419419
.get({
420420
headers: { authorization: `Bearer ${edgeToken}` },
@@ -431,7 +431,7 @@ describe('list', () => {
431431
directories: [],
432432
}),
433433
),
434-
url: `${edgeURL}/${siteID}/${storeName}?prefix=dir2%2F`,
434+
url: `${edgeURL}/${siteID}/site:${storeName}?prefix=dir2%2F`,
435435
})
436436

437437
globalThis.fetch = mockStore.fetch
@@ -487,7 +487,7 @@ describe('list', () => {
487487
next_cursor: 'cursor_1',
488488
}),
489489
),
490-
url: `${edgeURL}/${siteID}/${storeName}?directories=true`,
490+
url: `${edgeURL}/${siteID}/site:${storeName}?directories=true`,
491491
})
492492
.get({
493493
headers: { authorization: `Bearer ${edgeToken}` },
@@ -511,7 +511,7 @@ describe('list', () => {
511511
next_cursor: 'cursor_2',
512512
}),
513513
),
514-
url: `${edgeURL}/${siteID}/${storeName}?directories=true&cursor=cursor_1`,
514+
url: `${edgeURL}/${siteID}/site:${storeName}?directories=true&cursor=cursor_1`,
515515
})
516516
.get({
517517
headers: { authorization: `Bearer ${edgeToken}` },
@@ -528,7 +528,7 @@ describe('list', () => {
528528
directories: ['dir3'],
529529
}),
530530
),
531-
url: `${edgeURL}/${siteID}/${storeName}?directories=true&cursor=cursor_2`,
531+
url: `${edgeURL}/${siteID}/site:${storeName}?directories=true&cursor=cursor_2`,
532532
})
533533
.get({
534534
headers: { authorization: `Bearer ${edgeToken}` },
@@ -545,7 +545,7 @@ describe('list', () => {
545545
directories: [],
546546
}),
547547
),
548-
url: `${edgeURL}/${siteID}/${storeName}?prefix=dir2%2F&directories=true`,
548+
url: `${edgeURL}/${siteID}/site:${storeName}?prefix=dir2%2F&directories=true`,
549549
})
550550

551551
globalThis.fetch = mockStore.fetch
@@ -598,7 +598,7 @@ describe('list', () => {
598598
],
599599
}),
600600
),
601-
url: `${edgeURL}/${siteID}/${storeName}?prefix=group%2F`,
601+
url: `${edgeURL}/${siteID}/site:${storeName}?prefix=group%2F`,
602602
})
603603

604604
globalThis.fetch = mockStore.fetch
@@ -644,7 +644,7 @@ describe('list', () => {
644644
next_cursor: 'cursor_2',
645645
}),
646646
),
647-
url: `${edgeURL}/${siteID}/${storeName}`,
647+
url: `${edgeURL}/${siteID}/site:${storeName}`,
648648
})
649649
.get({
650650
headers: { authorization: `Bearer ${edgeToken}` },
@@ -667,7 +667,7 @@ describe('list', () => {
667667
next_cursor: 'cursor_3',
668668
}),
669669
),
670-
url: `${edgeURL}/${siteID}/${storeName}?cursor=cursor_2`,
670+
url: `${edgeURL}/${siteID}/site:${storeName}?cursor=cursor_2`,
671671
})
672672
.get({
673673
headers: { authorization: `Bearer ${edgeToken}` },
@@ -683,7 +683,7 @@ describe('list', () => {
683683
],
684684
}),
685685
),
686-
url: `${edgeURL}/${siteID}/${storeName}?cursor=cursor_3`,
686+
url: `${edgeURL}/${siteID}/site:${storeName}?cursor=cursor_3`,
687687
})
688688

689689
globalThis.fetch = mockStore.fetch

‎src/main.test.ts

Lines changed: 54 additions & 62 deletions
Large diffs are not rendered by default.

‎src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { getDeployStore, getStore } from './store_factory.ts'
2+
export { listStores } from './store_list.ts'
23
export { BlobsServer } from './server.ts'
34
export type {
45
Store,

‎src/store.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { getMetadataFromResponse, Metadata } from './metadata.ts'
77
import { BlobInput, HTTPMethod } from './types.ts'
88
import { BlobsInternalError, collectIterator } from './util.ts'
99

10+
export const DEPLOY_STORE_PREFIX = 'deploy:'
11+
export const SITE_STORE_PREFIX = 'site:'
12+
1013
interface BaseStoreOptions {
1114
client: Client
1215
consistency?: ConsistencyMode
@@ -64,21 +67,19 @@ export type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'tex
6467

6568
export class Store {
6669
private client: Client
67-
private consistency: ConsistencyMode
6870
private name: string
6971

7072
constructor(options: StoreOptions) {
7173
this.client = options.client
72-
this.consistency = options.consistency ?? 'eventual'
7374

7475
if ('deployID' in options) {
7576
Store.validateDeployID(options.deployID)
7677

77-
this.name = `deploy:${options.deployID}`
78+
this.name = DEPLOY_STORE_PREFIX + options.deployID
7879
} else {
7980
Store.validateStoreName(options.name)
8081

81-
this.name = options.name
82+
this.name = SITE_STORE_PREFIX + options.name
8283
}
8384
}
8485

@@ -261,6 +262,8 @@ export class Store {
261262
return iterator
262263
}
263264

265+
// We can't use `async/await` here because that would make the signature
266+
// incompatible with one of the overloads.
264267
// eslint-disable-next-line promise/prefer-await-to-then
265268
return collectIterator(iterator).then((items) =>
266269
items.reduce(
@@ -349,10 +352,6 @@ export class Store {
349352
}
350353

351354
private static validateStoreName(name: string) {
352-
if (name.startsWith('deploy:') || name.startsWith('deploy%3A1')) {
353-
throw new Error('Store name must not start with the `deploy:` reserved keyword.')
354-
}
355-
356355
if (name.includes('/') || name.includes('%2F')) {
357356
throw new Error('Store name must not contain forward slashes (/).')
358357
}

‎src/store_list.test.ts

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { Buffer } from 'node:buffer'
2+
import { env, version as nodeVersion } from 'node:process'
3+
4+
import semver from 'semver'
5+
import { describe, test, expect, beforeAll, afterEach } from 'vitest'
6+
7+
import { MockFetch } from '../test/mock_fetch.js'
8+
9+
import type { ListStoresResponse } from './backend/list_stores.js'
10+
import { listStores } from './main.js'
11+
12+
beforeAll(async () => {
13+
if (semver.lt(nodeVersion, '18.0.0')) {
14+
const nodeFetch = await import('node-fetch')
15+
16+
// @ts-expect-error Expected type mismatch between native implementation and node-fetch
17+
globalThis.fetch = nodeFetch.default
18+
// @ts-expect-error Expected type mismatch between native implementation and node-fetch
19+
globalThis.Request = nodeFetch.Request
20+
// @ts-expect-error Expected type mismatch between native implementation and node-fetch
21+
globalThis.Response = nodeFetch.Response
22+
// @ts-expect-error Expected type mismatch between native implementation and node-fetch
23+
globalThis.Headers = nodeFetch.Headers
24+
}
25+
})
26+
27+
afterEach(() => {
28+
delete env.NETLIFY_BLOBS_CONTEXT
29+
delete globalThis.netlifyBlobsContext
30+
})
31+
32+
const siteID = '9a003659-aaaa-0000-aaaa-63d3720d8621'
33+
const apiToken = 'some token'
34+
const edgeToken = 'some other token'
35+
const edgeURL = 'https://edge.netlify'
36+
37+
describe('listStores', () => {
38+
describe('With API credentials', () => {
39+
test('Lists site stores', async () => {
40+
const mockStore = new MockFetch().get({
41+
headers: { authorization: `Bearer ${apiToken}` },
42+
response: new Response(JSON.stringify({ stores: ['site:store1', 'site:store2', 'deploy:deploy1'] })),
43+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A`,
44+
})
45+
46+
globalThis.fetch = mockStore.fetch
47+
48+
const { stores } = await listStores({
49+
token: apiToken,
50+
siteID,
51+
})
52+
53+
expect(stores).toStrictEqual(['store1', 'store2'])
54+
expect(mockStore.fulfilled).toBeTruthy()
55+
})
56+
57+
test('Paginates automatically', async () => {
58+
const mockStore = new MockFetch()
59+
.get({
60+
headers: { authorization: `Bearer ${apiToken}` },
61+
response: new Response(
62+
JSON.stringify({
63+
stores: ['site:store1', 'site:store2', 'deploy:6527dfab35be400008332a1a'],
64+
next_cursor: 'cursor_1',
65+
}),
66+
),
67+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A`,
68+
})
69+
.get({
70+
headers: { authorization: `Bearer ${apiToken}` },
71+
response: new Response(
72+
JSON.stringify({
73+
stores: ['site:store3', 'site:store4', 'deploy:6527dfab35be400008332a1b'],
74+
next_cursor: 'cursor_2',
75+
}),
76+
),
77+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A&cursor=cursor_1`,
78+
})
79+
.get({
80+
headers: { authorization: `Bearer ${apiToken}` },
81+
response: new Response(JSON.stringify({ stores: ['site:store5', 'deploy:6527dfab35be400008332a1c'] })),
82+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A&cursor=cursor_2`,
83+
})
84+
85+
globalThis.fetch = mockStore.fetch
86+
87+
const { stores } = await listStores({
88+
token: apiToken,
89+
siteID,
90+
})
91+
92+
expect(stores).toStrictEqual(['store1', 'store2', 'store3', 'store4', 'store5'])
93+
expect(mockStore.fulfilled).toBeTruthy()
94+
})
95+
96+
test('Supports manual pagination', async () => {
97+
const mockStore = new MockFetch()
98+
.get({
99+
headers: { authorization: `Bearer ${apiToken}` },
100+
response: new Response(
101+
JSON.stringify({
102+
stores: ['site:store1', 'site:store2', 'deploy:6527dfab35be400008332a1a'],
103+
next_cursor: 'cursor_1',
104+
}),
105+
),
106+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A`,
107+
})
108+
.get({
109+
headers: { authorization: `Bearer ${apiToken}` },
110+
response: new Response(
111+
JSON.stringify({
112+
stores: ['site:store3', 'site:store4', 'deploy:6527dfab35be400008332a1b'],
113+
next_cursor: 'cursor_2',
114+
}),
115+
),
116+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A&cursor=cursor_1`,
117+
})
118+
.get({
119+
headers: { authorization: `Bearer ${apiToken}` },
120+
response: new Response(JSON.stringify({ stores: ['site:store5', 'deploy:6527dfab35be400008332a1c'] })),
121+
url: `https://api.netlify.com/api/v1/blobs/${siteID}?prefix=site%3A&cursor=cursor_2`,
122+
})
123+
124+
globalThis.fetch = mockStore.fetch
125+
126+
const result: ListStoresResponse = {
127+
stores: [],
128+
}
129+
130+
for await (const entry of listStores({ token: apiToken, siteID, paginate: true })) {
131+
result.stores.push(...entry.stores)
132+
}
133+
134+
expect(result.stores).toStrictEqual(['store1', 'store2', 'store3', 'store4', 'store5'])
135+
expect(mockStore.fulfilled).toBeTruthy()
136+
})
137+
})
138+
139+
describe('With edge credentials', () => {
140+
test('Lists site stores', async () => {
141+
const mockStore = new MockFetch().get({
142+
headers: { authorization: `Bearer ${edgeToken}` },
143+
response: new Response(JSON.stringify({ stores: ['site:store1', 'site:store2', 'deploy:deploy1'] })),
144+
url: `https://edge.netlify/${siteID}?prefix=site%3A`,
145+
})
146+
147+
globalThis.fetch = mockStore.fetch
148+
149+
const { stores } = await listStores({
150+
edgeURL,
151+
token: edgeToken,
152+
siteID,
153+
})
154+
155+
expect(stores).toStrictEqual(['store1', 'store2'])
156+
expect(mockStore.fulfilled).toBeTruthy()
157+
})
158+
159+
test('Loads credentials from the environment', async () => {
160+
const mockStore = new MockFetch().get({
161+
headers: { authorization: `Bearer ${edgeToken}` },
162+
response: new Response(JSON.stringify({ stores: ['site:store1', 'site:store2', 'deploy:deploy1'] })),
163+
url: `https://edge.netlify/${siteID}?prefix=site%3A`,
164+
})
165+
166+
globalThis.fetch = mockStore.fetch
167+
168+
const context = {
169+
edgeURL,
170+
siteID,
171+
token: edgeToken,
172+
}
173+
174+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
175+
176+
const { stores } = await listStores()
177+
178+
expect(stores).toStrictEqual(['store1', 'store2'])
179+
expect(mockStore.fulfilled).toBeTruthy()
180+
})
181+
182+
test('Paginates automatically', async () => {
183+
const mockStore = new MockFetch()
184+
.get({
185+
headers: { authorization: `Bearer ${edgeToken}` },
186+
response: new Response(
187+
JSON.stringify({
188+
stores: ['site:store1', 'site:store2', 'deploy:6527dfab35be400008332a1a'],
189+
next_cursor: 'cursor_1',
190+
}),
191+
),
192+
url: `https://edge.netlify/${siteID}?prefix=site%3A`,
193+
})
194+
.get({
195+
headers: { authorization: `Bearer ${edgeToken}` },
196+
response: new Response(
197+
JSON.stringify({
198+
stores: ['site:store3', 'site:store4', 'deploy:6527dfab35be400008332a1b'],
199+
next_cursor: 'cursor_2',
200+
}),
201+
),
202+
url: `https://edge.netlify/${siteID}?prefix=site%3A&cursor=cursor_1`,
203+
})
204+
.get({
205+
headers: { authorization: `Bearer ${edgeToken}` },
206+
response: new Response(JSON.stringify({ stores: ['site:store5', 'deploy:6527dfab35be400008332a1c'] })),
207+
url: `https://edge.netlify/${siteID}?prefix=site%3A&cursor=cursor_2`,
208+
})
209+
210+
globalThis.fetch = mockStore.fetch
211+
212+
const context = {
213+
edgeURL,
214+
siteID,
215+
token: edgeToken,
216+
}
217+
218+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
219+
220+
const { stores } = await listStores()
221+
222+
expect(stores).toStrictEqual(['store1', 'store2', 'store3', 'store4', 'store5'])
223+
expect(mockStore.fulfilled).toBeTruthy()
224+
})
225+
226+
test('Supports manual pagination', async () => {
227+
const mockStore = new MockFetch()
228+
.get({
229+
headers: { authorization: `Bearer ${edgeToken}` },
230+
response: new Response(
231+
JSON.stringify({
232+
stores: ['site:store1', 'site:store2', 'deploy:6527dfab35be400008332a1a'],
233+
next_cursor: 'cursor_1',
234+
}),
235+
),
236+
url: `https://edge.netlify/${siteID}?prefix=site%3A`,
237+
})
238+
.get({
239+
headers: { authorization: `Bearer ${edgeToken}` },
240+
response: new Response(
241+
JSON.stringify({
242+
stores: ['site:store3', 'site:store4', 'deploy:6527dfab35be400008332a1b'],
243+
next_cursor: 'cursor_2',
244+
}),
245+
),
246+
url: `https://edge.netlify/${siteID}?prefix=site%3A&cursor=cursor_1`,
247+
})
248+
.get({
249+
headers: { authorization: `Bearer ${edgeToken}` },
250+
response: new Response(JSON.stringify({ stores: ['site:store5', 'deploy:6527dfab35be400008332a1c'] })),
251+
url: `https://edge.netlify/${siteID}?prefix=site%3A&cursor=cursor_2`,
252+
})
253+
254+
globalThis.fetch = mockStore.fetch
255+
256+
const context = {
257+
edgeURL,
258+
siteID,
259+
token: edgeToken,
260+
}
261+
262+
env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')
263+
264+
const result: ListStoresResponse = {
265+
stores: [],
266+
}
267+
268+
for await (const entry of listStores({ paginate: true })) {
269+
result.stores.push(...entry.stores)
270+
}
271+
272+
expect(result.stores).toStrictEqual(['store1', 'store2', 'store3', 'store4', 'store5'])
273+
expect(mockStore.fulfilled).toBeTruthy()
274+
})
275+
})
276+
})

‎src/store_list.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { ListStoresResponse } from './backend/list_stores.ts'
2+
import { Client, ClientOptions, getClientOptions } from './client.ts'
3+
import { getEnvironmentContext } from './environment.ts'
4+
import { DEPLOY_STORE_PREFIX, SITE_STORE_PREFIX } from './store.ts'
5+
import { HTTPMethod } from './types.ts'
6+
import { collectIterator } from './util.ts'
7+
8+
export function listStores(options: Partial<ClientOptions> & { paginate: true }): AsyncIterable<ListStoresResponse>
9+
export function listStores(options?: Partial<ClientOptions> & { paginate?: false }): Promise<ListStoresResponse>
10+
export function listStores(
11+
options: Partial<ClientOptions> & { paginate?: boolean } = {},
12+
): AsyncIterable<ListStoresResponse> | Promise<ListStoresResponse> {
13+
const context = getEnvironmentContext()
14+
const clientOptions = getClientOptions(options, context)
15+
const client = new Client(clientOptions)
16+
const iterator = getListIterator(client, SITE_STORE_PREFIX)
17+
18+
if (options.paginate) {
19+
return iterator
20+
}
21+
22+
// We can't use `async/await` here because that would make the signature
23+
// incompatible with one of the overloads.
24+
// eslint-disable-next-line promise/prefer-await-to-then
25+
return collectIterator(iterator).then((results) => ({ stores: results.flatMap((page) => page.stores) }))
26+
}
27+
28+
const formatListStoreResponse = (stores: string[]) =>
29+
stores
30+
.filter((store) => !store.startsWith(DEPLOY_STORE_PREFIX))
31+
.map((store) => (store.startsWith(SITE_STORE_PREFIX) ? store.slice(SITE_STORE_PREFIX.length) : store))
32+
33+
const getListIterator = (client: Client, prefix: string): AsyncIterable<ListStoresResponse> => {
34+
const parameters: Record<string, string> = {
35+
prefix,
36+
}
37+
38+
return {
39+
[Symbol.asyncIterator]() {
40+
let currentCursor: string | null = null
41+
let done = false
42+
43+
return {
44+
async next() {
45+
if (done) {
46+
return { done: true, value: undefined }
47+
}
48+
49+
const nextParameters = { ...parameters }
50+
51+
if (currentCursor !== null) {
52+
nextParameters.cursor = currentCursor
53+
}
54+
55+
const res = await client.makeRequest({
56+
method: HTTPMethod.GET,
57+
parameters: nextParameters,
58+
})
59+
const page = (await res.json()) as ListStoresResponse
60+
61+
if (page.next_cursor) {
62+
currentCursor = page.next_cursor
63+
} else {
64+
done = true
65+
}
66+
67+
return {
68+
done: false,
69+
value: {
70+
...page,
71+
stores: formatListStoreResponse(page.stores),
72+
},
73+
}
74+
},
75+
}
76+
},
77+
}
78+
}

0 commit comments

Comments
 (0)
Please sign in to comment.