diff --git a/src/errors.js b/src/errors.js index eb52984..ca81b3a 100644 --- a/src/errors.js +++ b/src/errors.js @@ -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') @@ -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} diff --git a/src/routes.js b/src/routes.js index 2f12788..f0a9dcc 100644 --- a/src/routes.js +++ b/src/routes.js @@ -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 @@ -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 . - .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}`, @@ -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 . - 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'), @@ -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) diff --git a/test/observations-endpoint.js b/test/observations-endpoint.js index 72f5eba..440f85e 100644 --- a/test/observations-endpoint.js +++ b/test/observations-endpoint.js @@ -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) => { @@ -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() }, ), @@ -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, }) } @@ -193,28 +197,42 @@ function blobToAttachment(blob) { * @param {object} options * @param {FastifyInstance} options.server * @param {string} options.serverAddress + * @param {Pick} options.observation * @param {Record} options.observationFromApi * @returns {Promise} */ -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, + ) + }), ) } @@ -222,22 +240,41 @@ async function assertAttachmentsCanBeFetchedAsJpeg({ * @param {FastifyInstance} server * @param {string} serverAddress * @param {string} url + * @param {'photo' | 'audio'} expectedType * @returns {Promise} */ async function assertAttachmentAndVariantsCanBeFetched( server, serverAddress, url, + expectedType, ) { assert(url.startsWith(serverAddress)) - /** @type {Map} */ - const variantsToCheck = new Map([ - [null, FIXTURE_ORIGINAL_PATH], - ['original', FIXTURE_ORIGINAL_PATH], - ['preview', FIXTURE_PREVIEW_PATH], - ['thumbnail', FIXTURE_THUMBNAIL_PATH], - ]) + /** @type {Map} */ 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]) => { @@ -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,