Skip to content

Commit

Permalink
fix: fix metadata in local server (#112)
Browse files Browse the repository at this point in the history
  • Loading branch information
eduardoboucas authored Nov 14, 2023
1 parent f04eaa5 commit 9646519
Show file tree
Hide file tree
Showing 2 changed files with 148 additions and 99 deletions.
138 changes: 70 additions & 68 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,6 @@ const siteID = '9a003659-aaaa-0000-aaaa-63d3720d8621'
const token = 'my-very-secret-token'

test('Reads and writes from the file system', async () => {
const directory = await tmp.dir()
const server = new BlobsServer({
directory: directory.path,
token,
})
const { port } = await server.start()
const blobs = getStore({
edgeURL: `http://localhost:${port}`,
name: 'mystore',
token,
siteID,
})
const metadata = {
features: {
blobs: true,
Expand All @@ -51,27 +39,82 @@ test('Reads and writes from the file system', async () => {
name: 'Netlify',
}

await blobs.set('simple-key', 'value 1')
expect(await blobs.get('simple-key')).toBe('value 1')
// Store #1: Edge access
const directory1 = await tmp.dir()
const server1 = new BlobsServer({
directory: directory1.path,
token,
})
const { port: port1 } = await server1.start()
const store1 = getStore({
edgeURL: `http://localhost:${port1}`,
name: 'mystore1',
token,
siteID,
})

// Store #2: API access
const directory2 = await tmp.dir()
const server2 = new BlobsServer({
directory: directory2.path,
token,
})
const { port: port2 } = await server2.start()
const store2 = getStore({
apiURL: `http://localhost:${port2}`,
name: 'mystore2',
token,
siteID,
})

await blobs.set('simple-key', 'value 2', { metadata })
expect(await blobs.get('simple-key')).toBe('value 2')
for (const store of [store1, store2]) {
const list1 = await store.list()
expect(list1.blobs).toEqual([])
expect(list1.directories).toEqual([])

await blobs.set('parent/child', 'value 3')
expect(await blobs.get('parent/child')).toBe('value 3')
expect(await blobs.get('parent')).toBe(null)
await store.set('simple-key', 'value 1')
expect(await store.get('simple-key')).toBe('value 1')

const entry = await blobs.getWithMetadata('simple-key')
expect(entry?.metadata).toEqual(metadata)
await store.set('simple-key', 'value 2', { metadata })
expect(await store.get('simple-key')).toBe('value 2')

const entryMetadata = await blobs.getMetadata('simple-key')
expect(entryMetadata?.metadata).toEqual(metadata)
const list2 = await store.list()
expect(list2.blobs.length).toBe(1)
expect(list2.blobs[0].key).toBe('simple-key')
expect(list2.directories).toEqual([])

await blobs.delete('simple-key')
expect(await blobs.get('simple-key')).toBe(null)
await store.set('parent/child', 'value 3')
expect(await store.get('parent/child')).toBe('value 3')
expect(await store.get('parent')).toBe(null)

await server.stop()
await fs.rm(directory.path, { force: true, recursive: true })
const entry = await store.getWithMetadata('simple-key')
expect(entry?.metadata).toEqual(metadata)

const entryMetadata = await store.getMetadata('simple-key')
expect(entryMetadata?.metadata).toEqual(metadata)

const childEntryMetdata = await store.getMetadata('parent/child')
expect(childEntryMetdata?.metadata).toEqual({})

expect(await store.getWithMetadata('does-not-exist')).toBe(null)
expect(await store.getMetadata('does-not-exist')).toBe(null)

await store.delete('simple-key')
expect(await store.get('simple-key')).toBe(null)
expect(await store.getMetadata('simple-key')).toBe(null)
expect(await store.getWithMetadata('simple-key')).toBe(null)

const list3 = await store.list()
expect(list3.blobs.length).toBe(1)
expect(list3.blobs[0].key).toBe('parent/child')
expect(list3.directories).toEqual([])
}

await server1.stop()
await fs.rm(directory1.path, { force: true, recursive: true })

await server2.stop()
await fs.rm(directory2.path, { force: true, recursive: true })
})

test('Separates keys from different stores', async () => {
Expand Down Expand Up @@ -218,44 +261,3 @@ test('Lists entries', async () => {

expect(parachutesSongs2.directories).toEqual([])
})

test('Supports the API access interface', async () => {
const directory = await tmp.dir()
const server = new BlobsServer({
directory: directory.path,
token,
})
const { port } = await server.start()
const blobs = getStore({
apiURL: `http://localhost:${port}`,
name: 'mystore',
token,
siteID,
})
const metadata = {
features: {
blobs: true,
functions: true,
},
name: 'Netlify',
}

await blobs.set('simple-key', 'value 1')
expect(await blobs.get('simple-key')).toBe('value 1')

await blobs.set('simple-key', 'value 2', { metadata })
expect(await blobs.get('simple-key')).toBe('value 2')

await blobs.set('parent/child', 'value 3')
expect(await blobs.get('parent/child')).toBe('value 3')
expect(await blobs.get('parent')).toBe(null)

const entry = await blobs.getWithMetadata('simple-key')
expect(entry?.metadata).toEqual(metadata)

await blobs.delete('simple-key')
expect(await blobs.get('simple-key')).toBe(null)

await server.stop()
await fs.rm(directory.path, { force: true, recursive: true })
})
109 changes: 78 additions & 31 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,28 +71,48 @@ export class BlobsServer {
}

async delete(req: http.IncomingMessage, res: http.ServerResponse) {
const apiMatch = this.parseAPIRequest(req)

if (apiMatch) {
return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() }))
}

const url = new URL(req.url ?? '', this.address)
const { dataPath, key } = this.getLocalPaths(url)
const { dataPath, key, metadataPath } = this.getLocalPaths(url)

if (!dataPath || !key) {
return this.sendResponse(req, res, 400)
}

// Try to delete the metadata file, if one exists.
try {
await fs.rm(metadataPath, { force: true, recursive: true })
} catch {
// no-op
}

// Delete the data file.
try {
await fs.rm(dataPath, { recursive: true })
await fs.rm(dataPath, { force: true, recursive: true })
} catch (error: unknown) {
if (isNodeError(error) && error.code === 'ENOENT') {
return this.sendResponse(req, res, 404)
// An `ENOENT` error means we have tried to delete a key that doesn't
// exist, which shouldn't be treated as an error.
if (!isNodeError(error) || error.code !== 'ENOENT') {
return this.sendResponse(req, res, 500)
}

return this.sendResponse(req, res, 500)
}

return this.sendResponse(req, res, 200)
return this.sendResponse(req, res, 204)
}

async get(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url ?? '', this.address)
const apiMatch = this.parseAPIRequest(req)
const url = apiMatch?.url ?? new URL(req.url ?? '', this.address)

if (apiMatch?.key) {
return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() }))
}

const { dataPath, key, metadataPath, rootPath } = this.getLocalPaths(url)

if (!dataPath || !metadataPath) {
Expand Down Expand Up @@ -135,29 +155,29 @@ export class BlobsServer {
}

async head(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url ?? '', this.address)
const url = this.parseAPIRequest(req)?.url ?? new URL(req.url ?? '', this.address)
const { dataPath, key, metadataPath } = this.getLocalPaths(url)

if (!dataPath || !metadataPath || !key) {
return this.sendResponse(req, res, 400)
}

const headers: Record<string, string> = {}

try {
const rawData = await fs.readFile(metadataPath, 'utf8')
const metadata = JSON.parse(rawData)
const encodedMetadata = encodeMetadata(metadata)

if (encodedMetadata) {
headers[METADATA_HEADER_INTERNAL] = encodedMetadata
res.setHeader(METADATA_HEADER_INTERNAL, encodedMetadata)
}
} catch (error) {
if (isNodeError(error) && (error.code === 'ENOENT' || error.code === 'ISDIR')) {
return this.sendResponse(req, res, 404)
}

this.logDebug('Could not read metadata file:', error)
}

for (const name in headers) {
res.setHeader(name, headers[name])
return this.sendResponse(req, res, 500)
}

res.end()
Expand All @@ -182,9 +202,13 @@ export class BlobsServer {
try {
await BlobsServer.walk({ directories, path: dataPath, prefix, rootPath, result })
} catch (error) {
this.logDebug('Could not perform list:', error)
// If the directory is not found, it just means there are no entries on
// the store, so that shouldn't be treated as an error.
if (!isNodeError(error) || error.code !== 'ENOENT') {
this.logDebug('Could not perform list:', error)

return this.sendResponse(req, res, 500)
return this.sendResponse(req, res, 500)
}
}

res.setHeader('content-type', 'application/json')
Expand All @@ -193,6 +217,12 @@ export class BlobsServer {
}

async put(req: http.IncomingMessage, res: http.ServerResponse) {
const apiMatch = this.parseAPIRequest(req)

if (apiMatch) {
return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() }))
}

const url = new URL(req.url ?? '', this.address)
const { dataPath, key, metadataPath } = this.getLocalPaths(url)

Expand Down Expand Up @@ -263,19 +293,6 @@ export class BlobsServer {
return this.sendResponse(req, res, 403)
}

const apiURLMatch = req.url.match(API_URL_PATH)

// If this matches an API URL, return a signed URL.
if (apiURLMatch) {
const fullURL = new URL(req.url, this.address)
const storeName = fullURL.searchParams.get('context') ?? DEFAULT_STORE
const key = apiURLMatch.groups?.key as string
const siteID = apiURLMatch.groups?.site_id as string
const url = `${this.address}/${siteID}/${storeName}/${key}?signature=${this.tokenHash}`

return this.sendResponse(req, res, 200, JSON.stringify({ url }))
}

switch (req.method) {
case 'DELETE':
return this.delete(req, res)
Expand All @@ -295,8 +312,38 @@ export class BlobsServer {
}
}

/**
* Tries to parse a URL as being an API request and returns the different
* components, such as the store name, site ID, key, and signed URL.
*/
parseAPIRequest(req: http.IncomingMessage) {
if (!req.url) {
return null
}

const apiURLMatch = req.url.match(API_URL_PATH)

if (!apiURLMatch) {
return null
}

const fullURL = new URL(req.url, this.address)
const storeName = fullURL.searchParams.get('context') ?? DEFAULT_STORE
const key = apiURLMatch.groups?.key
const siteID = apiURLMatch.groups?.site_id as string
const urlPath = [siteID, storeName, key].filter(Boolean) as string[]
const url = new URL(`/${urlPath.join('/')}?signature=${this.tokenHash}`, this.address)

return {
key,
siteID,
storeName,
url,
}
}

sendResponse(req: http.IncomingMessage, res: http.ServerResponse, status: number, body?: string) {
this.logDebug(`${req.method} ${req.url}: ${status}`)
this.logDebug(`${req.method} ${req.url} ${status}`)

res.writeHead(status)
res.end(body)
Expand Down

0 comments on commit 9646519

Please sign in to comment.