Skip to content

Commit

Permalink
feat: return audio attachments from the API (#38)
Browse files Browse the repository at this point in the history
  • Loading branch information
EvanHahn authored Dec 10, 2024
1 parent ae9962b commit 2b669b9
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 37 deletions.
8 changes: 8 additions & 0 deletions src/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ class HttpError extends Error {
}
}

/** @param {string} message */
export const badRequestError = (message) =>
new HttpError(400, 'BAD_REQUEST', message)

export const invalidBearerToken = () =>
new HttpError(401, 'UNAUTHORIZED', 'Invalid bearer token')

Expand All @@ -39,6 +43,10 @@ export const tooManyProjects = () =>
export const projectNotFoundError = () =>
new HttpError(404, 'PROJECT_NOT_FOUND', 'Project not found')

/** @param {never} value */
export const shouldBeImpossibleError = (value) =>
new Error(`${value} should be impossible`)

/**
* @param {string} str
* @returns {string}
Expand Down
49 changes: 41 additions & 8 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const BASE32_STRING_32_BYTES = Type.String({ pattern: BASE32_REGEX_32_BYTES })

const INDEX_HTML_PATH = new URL('./static/index.html', import.meta.url)

const SUPPORTED_ATTACHMENT_TYPES = new Set(
/** @type {const} */ (['photo', 'audio']),
)

/**
* @typedef {object} RouteOptions
* @prop {string} serverBearerToken
Expand Down Expand Up @@ -299,9 +303,11 @@ export default async function routes(
lat: obs.lat,
lon: obs.lon,
attachments: obs.attachments
// TODO: For now, only photos are supported.
// See <https://github.com/digidem/comapeo-cloud/issues/25>.
.filter((attachment) => attachment.type === 'photo')
.filter((attachment) =>
SUPPORTED_ATTACHMENT_TYPES.has(
/** @type {any} */ (attachment.type),
),
)
.map((attachment) => ({
url: new URL(
`projects/${projectPublicId}/attachments/${attachment.driveDiscoveryId}/${attachment.type}/${attachment.name}`,
Expand Down Expand Up @@ -356,13 +362,18 @@ export default async function routes(
params: Type.Object({
projectPublicId: BASE32_STRING_32_BYTES,
driveDiscoveryId: Type.String(),
// TODO: For now, only photos are supported.
// See <https://github.com/digidem/comapeo-cloud/issues/25>.
type: Type.Literal('photo'),
type: Type.Union(
[...SUPPORTED_ATTACHMENT_TYPES].map((attachmentType) =>
Type.Literal(attachmentType),
),
),
name: Type.String(),
}),
querystring: Type.Object({
variant: Type.Optional(
// Not all of these are valid for all attachment types.
// For example, you can't get an audio's thumbnail.
// We do additional checking later to verify validity.
Type.Union([
Type.Literal('original'),
Type.Literal('preview'),
Expand All @@ -386,11 +397,33 @@ export default async function routes(
async function (req, reply) {
const project = await this.comapeo.getProject(req.params.projectPublicId)

let typeAndVariant
switch (req.params.type) {
case 'photo':
typeAndVariant = {
type: /** @type {const} */ ('photo'),
variant: req.query.variant || 'original',
}
break
case 'audio':
if (req.query.variant && req.query.variant !== 'original') {
throw errors.badRequestError(
'Cannot fetch this variant for audio attachments',
)
}
typeAndVariant = {
type: /** @type {const} */ ('audio'),
variant: /** @type {const} */ ('original'),
}
break
default:
throw errors.shouldBeImpossibleError(req.params.type)
}

const blobUrl = await project.$blobs.getUrl({
driveId: req.params.driveDiscoveryId,
name: req.params.name,
type: req.params.type,
variant: req.query.variant || 'original',
...typeAndVariant,
})

const proxiedResponse = await fetch(blobUrl)
Expand Down
95 changes: 66 additions & 29 deletions test/observations-endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ import {
/** @import { FastifyInstance } from 'fastify' */

const FIXTURES_ROOT = new URL('./fixtures/', import.meta.url)
const FIXTURE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT).pathname
const FIXTURE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT).pathname
const FIXTURE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT).pathname
const FIXTURE_IMAGE_ORIGINAL_PATH = new URL('original.jpg', FIXTURES_ROOT)
.pathname
const FIXTURE_IMAGE_PREVIEW_PATH = new URL('preview.jpg', FIXTURES_ROOT)
.pathname
const FIXTURE_IMAGE_THUMBNAIL_PATH = new URL('thumbnail.jpg', FIXTURES_ROOT)
.pathname
const FIXTURE_AUDIO_PATH = new URL('audio.mp3', FIXTURES_ROOT).pathname

test('returns a 401 if no auth is provided', async (t) => {
Expand Down Expand Up @@ -106,9 +109,9 @@ test('returning observations with fetchable attachments', async (t) => {
const [imageBlob, audioBlob] = await Promise.all([
project.$blobs.create(
{
original: FIXTURE_ORIGINAL_PATH,
preview: FIXTURE_PREVIEW_PATH,
thumbnail: FIXTURE_THUMBNAIL_PATH,
original: FIXTURE_IMAGE_ORIGINAL_PATH,
preview: FIXTURE_IMAGE_PREVIEW_PATH,
thumbnail: FIXTURE_IMAGE_THUMBNAIL_PATH,
},
{ mimeType: 'image/jpeg', timestamp: Date.now() },
),
Expand Down Expand Up @@ -156,9 +159,10 @@ test('returning observations with fetchable attachments', async (t) => {
assert.equal(observationFromApi.lon, observation.lon)
assert.equal(observationFromApi.deleted, observation.deleted)
if (!observationFromApi.deleted) {
await assertAttachmentsCanBeFetchedAsJpeg({
await assertAttachmentsCanBeFetched({
server,
serverAddress,
observation,
observationFromApi,
})
}
Expand Down Expand Up @@ -193,51 +197,84 @@ function blobToAttachment(blob) {
* @param {object} options
* @param {FastifyInstance} options.server
* @param {string} options.serverAddress
* @param {Pick<ObservationValue, 'attachments'>} options.observation
* @param {Record<string, unknown>} options.observationFromApi
* @returns {Promise<void>}
*/
async function assertAttachmentsCanBeFetchedAsJpeg({
async function assertAttachmentsCanBeFetched({
server,
serverAddress,
observation,
observationFromApi,
}) {
assert(Array.isArray(observationFromApi.attachments))

assert.equal(
observationFromApi.attachments.length,
observation.attachments.length,
'expected returned observation to have correct number of attachments',
)

await Promise.all(
observationFromApi.attachments.map(
/** @param {unknown} attachment */
async (attachment) => {
assert(attachment && typeof attachment === 'object')
assert('url' in attachment && typeof attachment.url === 'string')
await assertAttachmentAndVariantsCanBeFetched(
server,
serverAddress,
attachment.url,
)
},
),
observationFromApi.attachments.map(async (attachment, index) => {
const expectedType = (observation.attachments[index] || {}).type
assert(
expectedType === 'photo' || expectedType === 'audio',
'test setup: attachment is either photo or video',
)

assert(attachment && typeof attachment === 'object')
assert('url' in attachment && typeof attachment.url === 'string')

await assertAttachmentAndVariantsCanBeFetched(
server,
serverAddress,
attachment.url,
expectedType,
)
}),
)
}

/**
* @param {FastifyInstance} server
* @param {string} serverAddress
* @param {string} url
* @param {'photo' | 'audio'} expectedType
* @returns {Promise<void>}
*/
async function assertAttachmentAndVariantsCanBeFetched(
server,
serverAddress,
url,
expectedType,
) {
assert(url.startsWith(serverAddress))

/** @type {Map<null | string, string>} */
const variantsToCheck = new Map([
[null, FIXTURE_ORIGINAL_PATH],
['original', FIXTURE_ORIGINAL_PATH],
['preview', FIXTURE_PREVIEW_PATH],
['thumbnail', FIXTURE_THUMBNAIL_PATH],
])
/** @type {Map<null | string, string>} */ let variantsToCheck
/** @type {string} */ let expectedContentType
switch (expectedType) {
case 'photo':
variantsToCheck = new Map([
[null, FIXTURE_IMAGE_ORIGINAL_PATH],
['original', FIXTURE_IMAGE_ORIGINAL_PATH],
['preview', FIXTURE_IMAGE_PREVIEW_PATH],
['thumbnail', FIXTURE_IMAGE_THUMBNAIL_PATH],
])
expectedContentType = 'image/jpeg'
break
case 'audio':
variantsToCheck = new Map([
[null, FIXTURE_AUDIO_PATH],
['original', FIXTURE_AUDIO_PATH],
])
expectedContentType = 'audio/mpeg'
break
default: {
/** @type {never} */ const exhaustiveCheck = expectedType
assert.fail(`test setup:${exhaustiveCheck} should be impossible`)
}
}

await Promise.all(
map(variantsToCheck, async ([variant, fixturePath]) => {
Expand All @@ -254,8 +291,8 @@ async function assertAttachmentAndVariantsCanBeFetched(
)
assert.equal(
attachmentResponse.headers['content-type'],
'image/jpeg',
`expected ${variant} attachment to be a JPEG`,
expectedContentType,
`expected ${variant} attachment to be a ${expectedContentType}`,
)
assert.deepEqual(
attachmentResponse.rawPayload,
Expand Down

0 comments on commit 2b669b9

Please sign in to comment.