Skip to content
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

Merged
merged 18 commits into from
Aug 31, 2023
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions src/blob-api.js
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'> }>}
Copy link
Member

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.

{ driveId: string, name: string, type: 'photo' | 'video' | 'audio' }

Copy link
Contributor Author

@sethvincent sethvincent Aug 23, 2023

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 the create method?

Copy link
Member

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 as blobStore.writerDriveId, and use it from there. I've created an issue and made a fix

*/
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}`)
}
16 changes: 16 additions & 0 deletions src/blob-server/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { once } from 'events'
import fastify from 'fastify'

import BlobServerPlugin from './fastify-plugin.js'
Expand All @@ -23,3 +24,18 @@ export function createBlobServer({ logger, blobStore, prefix, projectId }) {
})
return server
}

/**
* @param {import('node:http').Server} server
* @returns {Promise<number>}
*/
export async function getPort(server) {
const address = server.address()

if (!address || !(typeof address === 'object') || !address.port) {
await once(server, 'listening')
return getPort(server)
}

return address.port
}
15 changes: 15 additions & 0 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { CoreManager } from './core-manager/index.js'
import { DataStore } from './datastore/index.js'
import { DataType } from './datatype/index.js'
import { BlobStore } from './blobstore/index.js'
import { BlobApi } from './blob-api.js'
import { IndexWriter } from './index-writer/index.js'
import { fieldTable, observationTable, presetTable } from './schema/project.js'
import RandomAccessFile from 'random-access-file'
Expand All @@ -23,9 +25,11 @@ const INDEXER_STORAGE_FOLDER_NAME = 'indexer'
const MAX_FILE_DESCRIPTORS = 768

export class MapeoProject {
#projectId
#coreManager
#dataStores
#dataTypes
#blobStore

/**
* @param {Object} opts
Expand All @@ -36,6 +40,8 @@ export class MapeoProject {
* @param {Partial<Record<import('./core-manager/index.js').Namespace, Buffer>>} [opts.encryptionKeys] Encryption keys for each namespace
*/
constructor({ storagePath, ...coreManagerOpts }) {
this.#projectId = coreManagerOpts.projectKey.toString('hex') // TODO: update based on outcome of https://github.com/digidem/mapeo-core-next/issues/171

///////// 1. Setup database

const dbPath =
Expand Down Expand Up @@ -113,6 +119,15 @@ export class MapeoProject {
db,
}),
}

this.#blobStore = new BlobStore({
coreManager: this.#coreManager,
})

this.$blobs = new BlobApi({
projectId: this.#projectId,
blobStore: this.#blobStore,
gmaclennan marked this conversation as resolved.
Show resolved Hide resolved
})
}

get observation() {
Expand Down
4 changes: 2 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import Corestore from 'corestore'
import Hypercore from 'hypercore'

type SupportedBlobVariants = typeof SUPPORTED_BLOB_VARIANTS
type BlobType = keyof SupportedBlobVariants
type BlobVariant<TBlobType extends BlobType> = TupleToUnion<
export type BlobType = keyof SupportedBlobVariants
export type BlobVariant<TBlobType extends BlobType> = TupleToUnion<
SupportedBlobVariants[TBlobType]
>

Expand Down
122 changes: 122 additions & 0 deletions tests/blob-api.js
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

View workflow job for this annotation

GitHub Actions / build (macos-latest, 16.x)

'basename' is defined but never used

Check failure on line 1 in tests/blob-api.js

View workflow job for this annotation

GitHub Actions / build (macos-latest, 18.x)

'basename' is defined but never used

Check failure on line 1 in tests/blob-api.js

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 16.x)

'basename' is defined but never used

Check failure on line 1 in tests/blob-api.js

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest, 18.x)

'basename' is defined but never used

Check failure on line 1 in tests/blob-api.js

View workflow job for this annotation

GitHub Actions / build (windows-latest, 16.x)

'basename' is defined but never used

Check failure on line 1 in tests/blob-api.js

View workflow job for this annotation

GitHub Actions / build (windows-latest, 18.x)

'basename' is defined but never used
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()
})
})
10 changes: 1 addition & 9 deletions tests/blob-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,11 @@ import test from 'brittle'
import { readdirSync } from 'fs'
import { readFile } from 'fs/promises'
import path from 'path'
import { createCoreManager } from './helpers/core-manager.js'
import { BlobStore } from '../src/blob-store/index.js'
import { createBlobServer } from '../src/blob-server/index.js'
import BlobServerPlugin from '../src/blob-server/fastify-plugin.js'
import fastify from 'fastify'

import { replicateBlobs } from './helpers/blob-store.js'
import { replicateBlobs, createBlobStore } from './helpers/blob-store.js'

test('Plugin throws error if missing getBlobStore option', async (t) => {
const server = fastify()
Expand Down Expand Up @@ -217,12 +215,6 @@ test('GET photo returns 404 when trying to get non-replicated blob', async (t) =
t.is(res.statusCode, 404)
})

function createBlobStore(opts) {
const coreManager = createCoreManager(opts)
const blobStore = new BlobStore({ coreManager })
return { blobStore, coreManager }
}

async function testenv({ prefix, logger } = {}) {
const projectKey = randomBytes(32)
const projectId = projectKey.toString('hex')
Expand Down
Binary file added tests/fixtures/blob-api/original.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/blob-api/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/fixtures/blob-api/thumbnail.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Empty file added tests/helpers/blob-server.js
sethvincent marked this conversation as resolved.
Show resolved Hide resolved
Empty file.
14 changes: 14 additions & 0 deletions tests/helpers/blob-store.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { replicate } from './core-manager.js'
import { pipelinePromise as pipeline, Writable } from 'streamx'

import { BlobStore } from '../../src/blob-store/index.js'
import { createCoreManager } from './core-manager.js'

/**
*
* @param {Object} options
* @returns
*/
export function createBlobStore(options = {}) {
const coreManager = createCoreManager(options)
const blobStore = new BlobStore({ coreManager })
return { blobStore, coreManager }
}

/**
*
* @param {import('../../src/core-manager/index.js').CoreManager} cm1
Expand Down
10 changes: 10 additions & 0 deletions tests/helpers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,13 @@ export async function waitForIndexing(stores) {
})
)
}

export async function timeoutException(promise, timeout = 100) {
const timer = new Promise((resolve) => {
setTimeout(() => {
resolve('timeout')
}, timeout)
})

return (await Promise.race([promise, timer])) === 'timeout'
}
Loading