Skip to content

Commit

Permalink
feat: add consistency configuration property (#143)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas authored Feb 8, 2024
1 parent 5fadad0 commit febd736
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 15 deletions.
31 changes: 28 additions & 3 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { BlobsConsistencyError, ConsistencyMode } from './consistency.ts'
import { EnvironmentContext, getEnvironmentContext, MissingBlobsEnvironmentError } from './environment.ts'
import { encodeMetadata, Metadata, METADATA_HEADER_EXTERNAL, METADATA_HEADER_INTERNAL } from './metadata.ts'
import { fetchAndRetry } from './retry.ts'
import { BlobInput, Fetcher, HTTPMethod } from './types.ts'

interface MakeStoreRequestOptions {
body?: BlobInput | null
consistency?: ConsistencyMode
headers?: Record<string, string>
key?: string
metadata?: Metadata
Expand All @@ -15,13 +17,16 @@ interface MakeStoreRequestOptions {

export interface ClientOptions {
apiURL?: string
consistency?: ConsistencyMode
edgeURL?: string
fetch?: Fetcher
siteID: string
token: string
uncachedEdgeURL?: string
}

interface GetFinalRequestOptions {
consistency?: ConsistencyMode
key: string | undefined
metadata?: Metadata
method: string
Expand All @@ -31,17 +36,21 @@ interface GetFinalRequestOptions {

export class Client {
private apiURL?: string
private consistency: ConsistencyMode
private edgeURL?: string
private fetch: Fetcher
private siteID: string
private token: string
private uncachedEdgeURL?: string

constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions) {
constructor({ apiURL, consistency, edgeURL, fetch, siteID, token, uncachedEdgeURL }: ClientOptions) {
this.apiURL = apiURL
this.consistency = consistency ?? 'eventual'
this.edgeURL = edgeURL
this.fetch = fetch ?? globalThis.fetch
this.siteID = siteID
this.token = token
this.uncachedEdgeURL = uncachedEdgeURL

if (!this.fetch) {
throw new Error(
Expand All @@ -50,10 +59,22 @@ export class Client {
}
}

private async getFinalRequest({ key, metadata, method, parameters = {}, storeName }: GetFinalRequestOptions) {
private async getFinalRequest({
consistency: opConsistency,
key,
metadata,
method,
parameters = {},
storeName,
}: GetFinalRequestOptions) {
const encodedMetadata = encodeMetadata(metadata)
const consistency = opConsistency ?? this.consistency

if (this.edgeURL) {
if (consistency === 'strong' && !this.uncachedEdgeURL) {
throw new BlobsConsistencyError()
}

const headers: Record<string, string> = {
authorization: `Bearer ${this.token}`,
}
Expand All @@ -63,7 +84,7 @@ export class Client {
}

const path = key ? `/${this.siteID}/${storeName}/${key}` : `/${this.siteID}/${storeName}`
const url = new URL(path, this.edgeURL)
const url = new URL(path, consistency === 'strong' ? this.uncachedEdgeURL : this.edgeURL)

for (const key in parameters) {
url.searchParams.set(key, parameters[key])
Expand Down Expand Up @@ -124,6 +145,7 @@ export class Client {

async makeRequest({
body,
consistency,
headers: extraHeaders,
key,
metadata,
Expand All @@ -132,6 +154,7 @@ export class Client {
storeName,
}: MakeStoreRequestOptions) {
const { headers: baseHeaders = {}, url } = await this.getFinalRequest({
consistency,
key,
metadata,
method,
Expand Down Expand Up @@ -184,10 +207,12 @@ export const getClientOptions = (

const clientOptions = {
apiURL: context.apiURL ?? options.apiURL,
consistency: options.consistency,
edgeURL: context.edgeURL ?? options.edgeURL,
fetch: options.fetch,
siteID,
token,
uncachedEdgeURL: context.uncachedEdgeURL ?? options.uncachedEdgeURL,
}

return clientOptions
Expand Down
11 changes: 11 additions & 0 deletions src/consistency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type ConsistencyMode = 'eventual' | 'strong'

export class BlobsConsistencyError extends Error {
constructor() {
super(
`Netlify Blobs has failed to perform a read using strong consistency because the environment has not been configured with a 'uncachedEdgeURL' property`,
)

this.name = 'BlobsConsistencyError'
}
}
1 change: 1 addition & 0 deletions src/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface EnvironmentContext {
edgeURL?: string
siteID?: string
token?: string
uncachedEdgeURL?: string
}

export const getEnvironmentContext = (): EnvironmentContext => {
Expand Down
180 changes: 180 additions & 0 deletions src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const apiToken = 'some token'
const signedURL = 'https://signed.url/123456789'
const edgeToken = 'some other token'
const edgeURL = 'https://edge.netlify'
const uncachedEdgeURL = 'https://uncached.edge.netlify'

describe('get', () => {
describe('With API credentials', () => {
Expand Down Expand Up @@ -1493,3 +1494,182 @@ describe(`getStore`, () => {
)
})
})

describe('Consistency configuration', () => {
test('Respects the consistency mode supplied in the operation methods', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const context = {
edgeURL,
siteID,
token: edgeToken,
uncachedEdgeURL,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

const blobs = getStore('production')

const data = await blobs.get(key, { consistency: 'strong' })
expect(data).toBe(value)

const meta = await blobs.getMetadata(key, { consistency: 'strong' })
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key, { consistency: 'strong' })
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

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

test('Respects the consistency mode supplied in the store constructor', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
consistency: 'strong',
edgeURL,
name: 'production',
token: edgeToken,
siteID,
uncachedEdgeURL,
})

const data = await blobs.get(key)
expect(data).toBe(value)

const meta = await blobs.getMetadata(key)
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key)
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

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

test('The consistency mode from the operation methods takes precedence over the store configuration', async () => {
const mockMetadata = {
name: 'Netlify',
cool: true,
functions: ['edge', 'serverless'],
}
const headers = {
etag: '123456789',
'x-amz-meta-user': `b64;${base64Encode(mockMetadata)}`,
}
const mockStore = new MockFetch()
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value),
url: `${uncachedEdgeURL}/${siteID}/production/${key}`,
})
.head({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(null, { headers }),
url: `${edgeURL}/${siteID}/production/${key}`,
})
.get({
headers: { authorization: `Bearer ${edgeToken}` },
response: new Response(value, { headers }),
url: `${edgeURL}/${siteID}/production/${key}`,
})

globalThis.fetch = mockStore.fetch

const blobs = getStore({
consistency: 'strong',
edgeURL,
name: 'production',
token: edgeToken,
siteID,
uncachedEdgeURL,
})

const data = await blobs.get(key)
expect(data).toBe(value)

const meta = await blobs.getMetadata(key, { consistency: 'eventual' })
expect(meta?.etag).toBe(headers.etag)
expect(meta?.metadata).toEqual(mockMetadata)

const dataWithMeta = await blobs.getWithMetadata(key, { consistency: 'eventual' })
expect(dataWithMeta?.data).toBe(value)
expect(dataWithMeta?.etag).toBe(headers.etag)
expect(dataWithMeta?.metadata).toEqual(mockMetadata)

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

test('Throws when strong consistency is used and no `uncachedEdgeURL` property has been defined', async () => {
const context = {
edgeURL,
siteID,
token: edgeToken,
}

env.NETLIFY_BLOBS_CONTEXT = Buffer.from(JSON.stringify(context)).toString('base64')

const store = getStore('productin')

await expect(async () => await store.get('my-key', { consistency: 'strong' })).rejects.toThrowError(
"Netlify Blobs has failed to perform a read using strong consistency because the environment has not been configured with a 'uncachedEdgeURL' property",
)
})
})
Loading

0 comments on commit febd736

Please sign in to comment.