-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: $blobs.getUrl and $blobs.create methods #184
Changes from 6 commits
562e12a
1a51ed3
6bd54d0
714a605
dae3c33
1005745
96dc00e
1a11b68
56cba57
f65dafd
3853a96
849805a
715d523
d2efec1
46e601b
13d6e53
04677d5
5a6e7b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import fs from 'node:fs' | ||
import { basename } from 'node:path' | ||
import sodium from 'sodium-universal' | ||
import b4a from 'b4a' | ||
|
||
import { getPort } from './blob-server/index.js' | ||
|
||
/** @typedef {import('./types.js').BlobId} BlobId */ | ||
/** @typedef {import('./types.js').BlobType} BlobType */ | ||
/** @typedef {import('./types.js').BlobVariant<BlobType>} BlobVariant */ | ||
|
||
export class BlobApi { | ||
/** | ||
* @param {object} options | ||
* @param {string} options.projectId | ||
* @param {import('./blob-store/index.js').BlobStore} options.blobStore | ||
* @param {import('fastify').FastifyInstance} options.blobServer | ||
*/ | ||
constructor({ projectId, blobStore, blobServer }) { | ||
this.projectId = projectId | ||
this.blobStore = blobStore | ||
this.blobServer = blobServer | ||
} | ||
|
||
/** | ||
* Get a url for a blob based on its BlobId | ||
* @param {import('./types.js').BlobId} blobId | ||
* @returns {Promise<string>} | ||
*/ | ||
async getUrl(blobId) { | ||
const { driveId, type, variant, name } = blobId | ||
const port = await getPort(this.blobServer.server) | ||
return `http://127.0.0.1:${port}/${this.projectId}/${driveId}/${type}/${variant}/${name}` | ||
} | ||
|
||
/** | ||
* Write blobs for provided variants of a file | ||
* @param {{ original: string, preview?: string, thumbnail?: string }} filepaths | ||
* @param {{ mimeType: string }} metadata | ||
* @returns {Promise<{ original: Omit<BlobId, 'driveId'>, preview?: Omit<BlobId, 'driveId'>, thumbnail?: Omit<BlobId, 'driveId'> }>} | ||
*/ | ||
async create(filepaths, metadata) { | ||
const { original, preview, thumbnail } = filepaths | ||
const { mimeType } = metadata | ||
const blobType = getType(mimeType) | ||
const hash = b4a.alloc(8) | ||
sodium.randombytes_buf(hash) | ||
const name = hash.toString('hex') | ||
|
||
const originalBlobId = await this.writeFile( | ||
original, | ||
{ | ||
name: `${name}_${basename(original)}`, | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
variant: 'original', | ||
type: blobType, | ||
}, | ||
metadata | ||
) | ||
const previewBlobId = preview | ||
? await this.writeFile( | ||
preview, | ||
{ | ||
name: `${name}_${basename(preview)}`, | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
variant: 'preview', | ||
type: blobType, | ||
}, | ||
metadata | ||
) | ||
: null | ||
const thumbnailBlobId = thumbnail | ||
? await this.writeFile( | ||
thumbnail, | ||
{ | ||
name: `${name}_${basename(thumbnail)}`, | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
variant: 'thumbnail', | ||
type: blobType, | ||
}, | ||
metadata | ||
) | ||
: null | ||
|
||
const blobIds = | ||
/** @type {{ original: Omit<BlobId, 'driveId'>, preview?: Omit<BlobId, 'driveId'>, thumbnail?: Omit<BlobId, 'driveId'> }} */ ({ | ||
original: originalBlobId, | ||
}) | ||
|
||
if (previewBlobId) blobIds.preview = previewBlobId | ||
if (thumbnailBlobId) blobIds.thumbnail = thumbnailBlobId | ||
|
||
return blobIds | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* @param {Omit<BlobId, 'driveId'>} options | ||
* @returns {Promise<Omit<BlobId, 'driveId'>>} | ||
*/ | ||
async writeFile(filepath, { name, variant, type }, metadata) { | ||
return new Promise((resolve, reject) => { | ||
fs.createReadStream(filepath) | ||
.pipe( | ||
this.blobStore.createWriteStream( | ||
{ type, variant, name }, | ||
{ metadata } | ||
) | ||
) | ||
.on('error', reject) | ||
.on('finish', () => { | ||
resolve({ type, variant, name }) | ||
}) | ||
}) | ||
} | ||
} | ||
|
||
/** | ||
* @param {string} mimeType | ||
* @returns {BlobType} | ||
*/ | ||
function getType(mimeType) { | ||
if (mimeType.startsWith('image')) return 'photo' | ||
if (mimeType.startsWith('video')) return 'video' | ||
if (mimeType.startsWith('audio')) return 'audio' | ||
|
||
throw new Error(`Unsupported mimeType: ${mimeType}`) | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
import { join, basename } from 'node:path' | ||
Check failure on line 1 in tests/blob-api.js GitHub Actions / build (macos-latest, 16.x)
Check failure on line 1 in tests/blob-api.js GitHub Actions / build (macos-latest, 18.x)
Check failure on line 1 in tests/blob-api.js GitHub Actions / build (ubuntu-latest, 16.x)
Check failure on line 1 in tests/blob-api.js GitHub Actions / build (ubuntu-latest, 18.x)
Check failure on line 1 in tests/blob-api.js GitHub Actions / build (windows-latest, 16.x)
|
||
import { fileURLToPath } from 'url' | ||
import test from 'brittle' | ||
import { BlobApi } from '../src/blob-api.js' | ||
import { createBlobServer, getPort } from '../src/blob-server/index.js' | ||
import { createBlobStore } from './helpers/blob-store.js' | ||
import { timeoutException } from './helpers/index.js' | ||
|
||
test('get port after listening event with explicit port', async (t) => { | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const blobStore = createBlobStore() | ||
const server = await createBlobServer({ blobStore }) | ||
|
||
t.ok(await timeoutException(getPort(server.server))) | ||
|
||
await new Promise((resolve) => { | ||
server.listen({ port: 3456 }, (err, address) => { | ||
resolve(address) | ||
}) | ||
}) | ||
|
||
const port = await getPort(server.server) | ||
|
||
t.is(typeof port, 'number') | ||
t.is(port, 3456) | ||
|
||
t.teardown(async () => { | ||
await server.close() | ||
}) | ||
}) | ||
|
||
test('get port after listening event with unset port', async (t) => { | ||
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const blobStore = createBlobStore() | ||
const server = await createBlobServer({ blobStore }) | ||
|
||
t.ok(await timeoutException(getPort(server.server))) | ||
|
||
await new Promise((resolve) => { | ||
server.listen({ port: 0 }, (err, address) => { | ||
resolve(address) | ||
}) | ||
}) | ||
|
||
const port = await getPort(server.server) | ||
|
||
t.is(typeof port, 'number', 'port is a number') | ||
t.teardown(async () => { | ||
await server.close() | ||
}) | ||
}) | ||
|
||
test('get url from blobId', async (t) => { | ||
const projectId = '1234' | ||
const driveId = '1234' | ||
const type = 'image' | ||
const variant = 'original' | ||
const name = '1234' | ||
|
||
const blobStore = createBlobStore() | ||
const blobServer = await createBlobServer({ blobStore }) | ||
const blobApi = new BlobApi({ projectId: '1234', blobStore, blobServer }) | ||
|
||
await new Promise((resolve) => { | ||
blobServer.listen({ port: 0 }, (err, address) => { | ||
resolve(address) | ||
}) | ||
}) | ||
|
||
const url = await blobApi.getUrl({ driveId, type, variant, name }) | ||
|
||
t.is( | ||
url, | ||
`http://127.0.0.1:${ | ||
blobServer.server.address().port | ||
}/${projectId}/${driveId}/${type}/${variant}/${name}` | ||
) | ||
t.teardown(async () => { | ||
await blobServer.close() | ||
}) | ||
}) | ||
|
||
test('create blobs', async (t) => { | ||
const { blobStore } = createBlobStore() | ||
const blobServer = await createBlobServer({ blobStore }) | ||
const blobApi = new BlobApi({ projectId: '1234', blobStore, blobServer }) | ||
|
||
await new Promise((resolve) => { | ||
blobServer.listen({ port: 0 }, (err, address) => { | ||
resolve(address) | ||
}) | ||
}) | ||
|
||
const directory = fileURLToPath( | ||
new URL('./fixtures/blob-api/', import.meta.url) | ||
) | ||
|
||
const blobIds = await blobApi.create( | ||
{ | ||
original: join(directory, 'original.png'), | ||
preview: join(directory, 'preview.png'), | ||
thumbnail: join(directory, 'thumbnail.png'), | ||
}, | ||
{ | ||
mimeType: 'image/png', | ||
} | ||
) | ||
|
||
t.is(blobIds.original.type, 'photo') | ||
t.is(blobIds.original.variant, 'original') | ||
t.ok(blobIds.original.name.includes('_original.png')) | ||
|
||
t.is(blobIds.preview.type, 'photo') | ||
t.is(blobIds.preview.variant, 'preview') | ||
t.ok(blobIds.preview.name.includes('_preview.png')) | ||
|
||
t.is(blobIds.thumbnail.type, 'photo') | ||
t.is(blobIds.thumbnail.variant, 'thumbnail') | ||
t.ok(blobIds.thumbnail.name.includes('_thumbnail.png')) | ||
|
||
t.teardown(async () => { | ||
await blobServer.close() | ||
}) | ||
}) |
sethvincent marked this conversation as resolved.
Show resolved
Hide resolved
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will actually need to return type
Observation['attachments'][number]
e.g.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
so far this module hasn't known about
driveId
. Would that be passed in thecreate
method?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah very good point. It's actually a property on the writeStream e.g.
blobStore.createWriteStream().driveId
although I just realised that was not documented (my bad) and it's an awkward API too. Since the driveId for any written blobs will never change for a particular instance of the blobStore, I think it makes sense to just expose it asblobStore.writerDriveId
, and use it from there. I've created an issue and made a fix