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: Icon HTTP Server #315

Merged
merged 45 commits into from
Nov 9, 2023
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
a7bca30
update project database
Oct 10, 2023
30ed013
update client database
Oct 10, 2023
93852ce
merge drizzle stuff
Oct 10, 2023
29cdb05
merge fix
Oct 4, 2023
b971e76
initial implementation of icon http server as fastify plugin
Oct 4, 2023
a8f84ac
move `options` outside of req callback, handle missing core on response
Oct 4, 2023
dc17c8b
move `iconServer` to `MapeoManager`
Oct 5, 2023
cff8904
create fastify server on MapeoManager
Oct 5, 2023
7f24973
try to validate 26-byte z-base-32 projectId on iconServer
Oct 5, 2023
bd7d7fc
fix regex (z-base32 uses every alphanumeric lowecase letters)
Oct 5, 2023
5f25b9e
include also uppercase letters on z-base-32 validation
Oct 5, 2023
44ec2bf
explain pattern of params, update to clearer variable names
Oct 5, 2023
cea2984
add `Icon` table and dataType, regenerate drizzle
Oct 3, 2023
e36878a
merge fix
Oct 4, 2023
2378bdf
initial implementation of icon http server as fastify plugin
Oct 4, 2023
5ea22d6
move `options` outside of req callback, handle missing core on response
Oct 4, 2023
b520e6e
move `iconServer` to `MapeoManager`
Oct 5, 2023
3c991cc
create fastify server on MapeoManager
Oct 5, 2023
459f261
try to validate 26-byte z-base-32 projectId on iconServer
Oct 5, 2023
15ef9d9
fix regex (z-base32 uses every alphanumeric lowecase letters)
Oct 5, 2023
c47780a
include also uppercase letters on z-base-32 validation
Oct 5, 2023
e096e97
explain pattern of params, update to clearer variable names
Oct 5, 2023
0fb8f6b
Integrate IconApi, expose dataTypes through symbol in mapeoProject
Oct 10, 2023
8ff431e
set mimeType header when returning a response
Oct 10, 2023
8528860
expose iconApi from mapeoProject instead of instancing every request
Oct 11, 2023
64927ed
make port optional, allow passing 0 so the OS can choose the port
tomasciccola Oct 11, 2023
bfe5664
make projectId description more accurrate
tomasciccola Oct 11, 2023
419ae73
change REGEX variable name, for accurracy
tomasciccola Oct 11, 2023
d6e387a
refactor Z_BASE_32 variable
tomasciccola Oct 11, 2023
9c77498
fix typo on port passed to fastify server
Oct 11, 2023
24b8021
Set fastify `getProject` method as a decorator
Oct 11, 2023
8734e6c
remove unnecessary drizzle stuff
Oct 11, 2023
76e75be
dangling drizzle stuff
Oct 11, 2023
e671fbb
added fastify declaration to merge `getProject` with `FastifyInstance`
Oct 11, 2023
44a83ed
remove integration of server from MapeoManager
achou11 Nov 6, 2023
a7ca99a
remove sql file added because of botched rebase
achou11 Nov 7, 2023
f45658b
fix instantiation of IconApi in MapeoProject after rebase
achou11 Nov 7, 2023
3af655b
update plugin implementation
achou11 Nov 7, 2023
0af565f
move plugin location
achou11 Nov 7, 2023
14325af
remove IconApi setup in MapeoProject
achou11 Nov 7, 2023
f65d694
Revert "remove IconApi setup in MapeoProject"
achou11 Nov 7, 2023
8655dcd
remove unused symbol
achou11 Nov 7, 2023
b48ec9a
improve route params validation and add basic tests
achou11 Nov 8, 2023
f4d0d28
small formatting fixes in test
achou11 Nov 8, 2023
cc76864
Merge branch 'main' into feat/IconHttpServer
gmaclennan Nov 9, 2023
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
5 changes: 5 additions & 0 deletions src/fastify-plugins/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// hex encoded 32-byte string
export const HEX_REGEX_32_BYTES = '^[0-9a-fA-F]{64}$'

// z-base-32 encoded 32-byte string (52 characters)
export const Z_BASE_32_REGEX_32_BYTES = '^[0-9a-zA-Z]{52}$'
153 changes: 153 additions & 0 deletions src/fastify-plugins/icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { Type as T } from '@sinclair/typebox'
import fp from 'fastify-plugin'
import { docSchemas } from '@mapeo/schema'

import { kGetIconBlob } from '../icon-api.js'
import { HEX_REGEX_32_BYTES, Z_BASE_32_REGEX_32_BYTES } from './constants.js'

export default fp(iconServerPlugin, {
fastify: '4.x',
name: 'mapeo-icons',
})

const ICON_DOC_ID_STRING = T.String({ pattern: HEX_REGEX_32_BYTES })
const PROJECT_PUBLIC_ID_STRING = T.String({ pattern: Z_BASE_32_REGEX_32_BYTES })

const VALID_SIZES =
docSchemas.icon.properties.variants.items.properties.size.enum
const VALID_MIME_TYPES =
docSchemas.icon.properties.variants.items.properties.mimeType.enum
const VALID_PIXEL_DENSITIES =
docSchemas.icon.properties.variants.items.properties.pixelDensity.enum

const PARAMS_JSON_SCHEMA = T.Object({
iconDocId: ICON_DOC_ID_STRING,
projectPublicId: PROJECT_PUBLIC_ID_STRING,
iconInfo: T.String({
pattern: `^(${VALID_SIZES.join('|')})(@(${VALID_PIXEL_DENSITIES.join(
'|'
)}+)x)?$`,
}),
mimeTypeExtension: T.Union(
VALID_MIME_TYPES.map((mimeType) => {
switch (mimeType) {
case 'image/png':
return T.Literal('png')
case 'image/svg+xml':
return T.Literal('svg')
}
})
),
})

/**
* @typedef {Object} IconServerPluginOpts
*
* @property {(projectId: string) => Promise<import('../mapeo-project.js').MapeoProject>} getProject
**/

/** @type {import('fastify').FastifyPluginAsync<import('fastify').RegisterOptions & IconServerPluginOpts>} */
async function iconServerPlugin(fastify, options) {
if (!options.getProject) throw new Error('Missing getProject')
fastify.register(routes, options)
}

/** @type {import('fastify').FastifyPluginAsync<Omit<IconServerPluginOpts, 'prefix'>, import('fastify').RawServerDefault, import('@fastify/type-provider-typebox').TypeBoxTypeProvider>} */
async function routes(fastify, options) {
const { getProject } = options

fastify.get(
'/:projectPublicId/:iconDocId/:iconInfo.:mimeTypeExtension',
{ schema: { params: PARAMS_JSON_SCHEMA } },
async (req, res) => {
const { projectPublicId, iconDocId, iconInfo, mimeTypeExtension } =
req.params

const { size, pixelDensity } = extractSizeAndPixelDensity(iconInfo)

const project = await getProject(projectPublicId)

const mimeType =
mimeTypeExtension === 'png' ? 'image/png' : 'image/svg+xml'

try {
const icon = await project.$icons[kGetIconBlob](
iconDocId,
mimeType === 'image/svg+xml'
? {
size,
mimeType,
}
: {
size,
pixelDensity,
mimeType,
}
)

res.header('Content-Type', mimeType)
return res.send(icon)
} catch (err) {
res.code(404)
throw err
}
}
)
}

// matches strings that end in `@_x` and captures `_`, where `_` is a positive integer
const DENSITY_MATCH_REGEX = /@(\d+)x$/i

/**
* @param {string} input
*
* @return {Pick<import('@mapeo/schema').Icon['variants'][number], 'size' | 'pixelDensity'>}
*/
function extractSizeAndPixelDensity(input) {
const result = DENSITY_MATCH_REGEX.exec(input)

if (result) {
const [match, capturedDensity] = result
const size = input.split(match, 1)[0]
const pixelDensity = parseInt(capturedDensity, 10)

assertValidSize(size)
assertValidPixelDensity(pixelDensity)

return { size, pixelDensity }
}

assertValidSize(input)

return { size: input, pixelDensity: 1 }
}

/**
* @param {string} value
* @returns {asserts value is import('@mapeo/schema').Icon['variants'][number]['size']}
*/
function assertValidSize(value) {
if (
!VALID_SIZES.includes(
// @ts-expect-error
value
)
) {
throw new Error(`'${value}' is not a valid icon size`)
}
}

/**
* @param {number} value
* @returns {asserts value is import('@mapeo/schema').Icon['variants'][number]['pixelDensity']}
*/
function assertValidPixelDensity(value) {
if (
!VALID_PIXEL_DENSITIES.includes(
// @ts-expect-error
value
)
) {
throw new Error(`${value} is not a valid icon pixel density`)
}
}
19 changes: 19 additions & 0 deletions src/mapeo-project.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { Capabilities } from './capabilities.js'
import { getDeviceId, projectKeyToId, valueOf } from './utils.js'
import { MemberApi } from './member-api.js'
import { SyncController } from './sync/sync-controller.js'
import { IconApi } from './icon-api.js'

/** @typedef {Omit<import('@mapeo/schema').ProjectSettingsValue, 'schemaName'>} EditableProjectSettings */

Expand All @@ -56,6 +57,7 @@ export class MapeoProject {
#ownershipWriteDone
#memberApi
#syncController
#iconApi

/**
* @param {Object} opts
Expand Down Expand Up @@ -244,6 +246,16 @@ export class MapeoProject {
},
})

this.#iconApi = new IconApi({
iconDataStore: this.#dataStores.config,
iconDataType: this.#dataTypes.icon,
projectId: this.#projectId,
// TODO: Update after merging https://github.com/digidem/mapeo-core-next/pull/365
getMediaBaseUrl: async () => {
throw new Error('Not yet implemented')
},
})

this.#syncController = new SyncController({
coreManager: this.#coreManager,
capabilities: this.#capabilities,
Expand Down Expand Up @@ -429,6 +441,13 @@ export class MapeoProject {
schemaName: 'deviceInfo',
})
}

/**
* @returns {import('./icon-api.js').IconApi}
*/
get $icons() {
return this.#iconApi
}
}

/**
Expand Down
141 changes: 141 additions & 0 deletions tests/fastify-plugins/icons.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// @ts-check
import { test } from 'brittle'
import { randomBytes } from 'crypto'
import fastify from 'fastify'

import IconServerPlugin from '../../src/fastify-plugins/icons.js'
import { projectKeyToPublicId } from '../../src/utils.js'

test('Plugin throws error if missing getProject option', async (t) => {
const server = fastify()
await t.exception(() => server.register(IconServerPlugin))
})

test('Plugin handles prefix option properly', async (t) => {
const prefix = 'icons'

const server = fastify()

server.register(IconServerPlugin, {
prefix,
getProject: async () => {
throw new Error('Not implemented')
},
})

const response = await server.inject({
method: 'GET',
url: `${prefix}/${buildIconUrl({
projectPublicId: projectKeyToPublicId(randomBytes(32)),
iconId: randomBytes(32).toString('hex'),
size: 'small',
extension: 'png',
})}`,
})

t.not(response.statusCode, 404, 'returns non-404 status code')
})

test('url param validation', async (t) => {
const server = fastify()

server.register(IconServerPlugin, {
getProject: async () => {
throw new Error('Not implemented')
},
})

const projectPublicId = projectKeyToPublicId(randomBytes(32))
const iconId = randomBytes(32).toString('hex')

/** @type {Array<[string, Parameters<typeof buildIconUrl>[0]]>} */
const fixtures = [
[
'invalid project public id',
{
projectPublicId: randomBytes(32).toString('hex'),
iconId,
size: 'small',
extension: 'png',
},
],
[
'invalid icon id',
{
projectPublicId,
iconId: randomBytes(16).toString('hex'),
size: 'small',
extension: 'png',
},
],
[
'invalid pixel density',
{
projectPublicId,
iconId,
size: 'small',
extension: 'png',
pixelDensity: 10,
},
],
[
'invalid size',
{
projectPublicId,
iconId,
size: 'foo',
extension: 'svg',
},
],
[
'invalid extension',
{
projectPublicId,
iconId,
size: 'small',
extension: 'foo',
},
],
]

await Promise.all(
fixtures.map(async ([name, input]) => {
const response = await server.inject({
method: 'GET',
url: buildIconUrl(input),
})

t.comment(name)

t.is(response.statusCode, 400, 'returns expected status code')
t.is(
response.json().code,
'FST_ERR_VALIDATION',
'error is validation error'
)
})
)
})

/**
*
* @param {object} opts
* @param {string} opts.projectPublicId
* @param {string} opts.iconId
* @param {string} opts.size
* @param {number} [opts.pixelDensity]
* @param {string} opts.extension
*
* @returns {string}
*/
function buildIconUrl({
projectPublicId,
iconId,
size,
pixelDensity,
extension,
}) {
const densitySuffix =
typeof pixelDensity === 'number' ? `@${pixelDensity}x` : ''
return `${projectPublicId}/${iconId}/${size}${densitySuffix}.${extension}`
}