Skip to content

Commit

Permalink
feat: add support for conditional requests (#76)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas authored Oct 23, 2023
1 parent dc209d7 commit 82df6ad
Show file tree
Hide file tree
Showing 4 changed files with 133 additions and 21 deletions.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const entry = await blobs.get('some-key', { type: 'json' })
console.log(entry)
```

### `getWithMetadata(key: string, { type?: string }): Promise<{ data: any, etag: string, metadata: object }>`
### `getWithMetadata(key: string, { etag?: string, type?: string }): Promise<{ data: any, etag: string, metadata: object }>`

Retrieves an object with the given key, the [ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag)
for the entry, and any metadata that has been stored with the entry.
Expand All @@ -214,6 +214,25 @@ const blob = await blobs.getWithMetadata('some-key', { type: 'json' })
console.log(blob.data, blob.etag, blob.metadata)
```

The `etag` input parameter lets you implement conditional requests, where the blob is only returned if it differs from a
version you have previously obtained.

```javascript
// Mock implementation of a system for locally persisting blobs and their etags
const cachedETag = getFromMockCache('my-key')

// Get entry from the blob store only if its ETag is different from the one you
// have locally, which means the entry has changed since you last obtained it
const { data, etag, fresh } = await blobs.getWithMetadata('some-key', { etag: cachedETag })

if (fresh) {
// `data` is `null` because the local blob is fresh
} else {
// `data` contains the new blob, store it locally alongside the new ETag
writeInMockCache('my-key', data, etag)
}
```

### `set(key: string, value: ArrayBuffer | Blob | ReadableStream | string, { metadata?: object }): Promise<void>`

Creates an object with the given key and value.
Expand Down
2 changes: 1 addition & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export class Client {
return null
}

if (res.status !== 200) {
if (res.status !== 200 && res.status !== 304) {
throw new Error(`${method} operation has failed: store returned a ${res.status} response`)
}

Expand Down
55 changes: 55 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,61 @@ describe('getWithMetadata', () => {

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

test('Supports conditional requests', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const etags = ['"thewrongetag"', '"therightetag"']
const metadataHeaders = {
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: `${signedURL}b` })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
})
.get({
headers: { 'if-none-match': etags[0] },
response: new Response(value, { headers: { ...metadataHeaders, etag: etags[0] }, status: 200 }),
url: `${signedURL}b`,
})
.get({
headers: { authorization: `Bearer ${apiToken}` },
response: new Response(JSON.stringify({ url: `${signedURL}a` })),
url: `https://api.netlify.com/api/v1/sites/${siteID}/blobs/${key}?context=production`,
})
.get({
headers: { 'if-none-match': etags[1] },
response: new Response(null, { headers: { ...metadataHeaders, etag: etags[0] }, status: 304 }),
url: `${signedURL}a`,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
name: 'production',
token: apiToken,
siteID,
})

const staleEntry = await blobs.getWithMetadata(key, { etag: etags[0] })
expect(staleEntry.data).toBe(value)
expect(staleEntry.etag).toBe(etags[0])
expect(staleEntry.fresh).toBe(false)
expect(staleEntry.metadata).toEqual(mockMetadata)

const freshEntry = await blobs.getWithMetadata(key, { etag: etags[1], type: 'text' })
expect(freshEntry.data).toBe(null)
expect(freshEntry.etag).toBe(etags[0])
expect(freshEntry.fresh).toBe(true)
expect(freshEntry.metadata).toEqual(mockMetadata)

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

describe('With edge credentials', () => {
Expand Down
76 changes: 57 additions & 19 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ interface NamedStoreOptions extends BaseStoreOptions {

type StoreOptions = DeployStoreOptions | NamedStoreOptions

interface GetWithMetadataOptions {
etag?: string
}

interface GetWithMetadataResult {
etag?: string
fresh: boolean
metadata: Metadata
}

interface SetOptions {
/**
* Arbitrary metadata object to associate with an entry. Must be seralizable
Expand All @@ -24,7 +34,6 @@ interface SetOptions {
metadata?: Metadata
}

type BlobWithMetadata = { etag?: string } & { metadata: Metadata }
type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text'

export class Store {
Expand Down Expand Up @@ -88,34 +97,53 @@ export class Store {
throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`)
}

async getWithMetadata(key: string): Promise<{ data: string } & BlobWithMetadata>
async getWithMetadata(
key: string,
options?: GetWithMetadataOptions,
): Promise<{ data: string } & GetWithMetadataResult>

async getWithMetadata(
key: string,
{ type }: { type: 'arrayBuffer' },
): Promise<{ data: ArrayBuffer } & BlobWithMetadata>
options: { type: 'arrayBuffer' } & GetWithMetadataOptions,
): Promise<{ data: ArrayBuffer } & GetWithMetadataResult>

async getWithMetadata(key: string, { type }: { type: 'blob' }): Promise<{ data: Blob } & BlobWithMetadata>
async getWithMetadata(
key: string,
options: { type: 'blob' } & GetWithMetadataOptions,
): Promise<{ data: Blob } & GetWithMetadataResult>

// eslint-disable-next-line @typescript-eslint/no-explicit-any
async getWithMetadata(key: string, { type }: { type: 'json' }): Promise<{ data: any } & BlobWithMetadata>
/* eslint-disable @typescript-eslint/no-explicit-any */

async getWithMetadata(key: string, { type }: { type: 'stream' }): Promise<{ data: ReadableStream } & BlobWithMetadata>
async getWithMetadata(
key: string,
options: { type: 'json' } & GetWithMetadataOptions,
): Promise<{ data: any } & GetWithMetadataResult>

async getWithMetadata(key: string, { type }: { type: 'text' }): Promise<{ data: string } & BlobWithMetadata>
/* eslint-enable @typescript-eslint/no-explicit-any */

async getWithMetadata(
key: string,
options?: { type: BlobResponseType },
options: { type: 'stream' } & GetWithMetadataOptions,
): Promise<{ data: ReadableStream } & GetWithMetadataResult>

async getWithMetadata(
key: string,
options: { type: 'text' } & GetWithMetadataOptions,
): Promise<{ data: string } & GetWithMetadataResult>

async getWithMetadata(
key: string,
options?: { type: BlobResponseType } & GetWithMetadataOptions,
): Promise<
| ({
data: ArrayBuffer | Blob | ReadableStream | string | null
} & BlobWithMetadata)
} & GetWithMetadataResult)
| null
> {
const { type } = options ?? {}
const res = await this.client.makeRequest({ key, method: HTTPMethod.GET, storeName: this.name })
const etag = res?.headers.get('etag') ?? undefined
const { etag: requestETag, type } = options ?? {}
const headers = requestETag ? { 'if-none-match': requestETag } : undefined
const res = await this.client.makeRequest({ headers, key, method: HTTPMethod.GET, storeName: this.name })
const responseETag = res?.headers.get('etag') ?? undefined

let metadata: Metadata = {}

Expand All @@ -131,24 +159,34 @@ export class Store {
return null
}

const result: GetWithMetadataResult = {
etag: responseETag,
fresh: false,
metadata,
}

if (res.status === 304 && requestETag) {
return { data: null, ...result, fresh: true }
}

if (type === undefined || type === 'text') {
return { data: await res.text(), etag, metadata }
return { data: await res.text(), ...result }
}

if (type === 'arrayBuffer') {
return { data: await res.arrayBuffer(), etag, metadata }
return { data: await res.arrayBuffer(), ...result }
}

if (type === 'blob') {
return { data: await res.blob(), etag, metadata }
return { data: await res.blob(), ...result }
}

if (type === 'json') {
return { data: await res.json(), etag, metadata }
return { data: await res.json(), ...result }
}

if (type === 'stream') {
return { data: res.body, etag, metadata }
return { data: res.body, ...result }
}

throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`)
Expand Down

0 comments on commit 82df6ad

Please sign in to comment.