diff options
-rw-r--r-- | api/routes/github.js | 17 | ||||
-rw-r--r-- | api/services/github/api.js | 5 | ||||
-rw-r--r-- | api/services/github/files.js | 35 | ||||
-rw-r--r-- | api/services/github/installations.js | 17 | ||||
-rw-r--r-- | api/services/zmk/keymap.js | 50 | ||||
-rw-r--r-- | api/services/zmk/layout.js | 65 |
6 files changed, 164 insertions, 25 deletions
diff --git a/api/routes/github.js b/api/routes/github.js index 529af4a..241b40f 100644 --- a/api/routes/github.js +++ b/api/routes/github.js @@ -15,7 +15,9 @@ const { InvalidRepoError, } = require('../services/github') const { createInstallationToken } = require('../services/github/auth') -const { parseKeymap } = require('../services/zmk/keymap') +const { MissingRepoFile } = require('../services/github/files') +const { parseKeymap, validateKeymapJson, KeymapValidationError } = require('../services/zmk/keymap') +const { validateInfoJson, InfoValidationError } = require('../services/zmk/layout') const router = Router() @@ -102,12 +104,21 @@ const getKeyboardFiles = async (req, res, next) => { try { const keyboardFiles = await fetchKeyboardFiles(installationId, repository, branch) + validateInfoJson(keyboardFiles.info) + validateKeymapJson(keyboardFiles.keymap) keyboardFiles.keymap = parseKeymap(keyboardFiles.keymap) res.json(keyboardFiles) } catch (err) { - if (err instanceof InvalidRepoError) { + if (err instanceof MissingRepoFile) { return res.status(400).json({ - error: 'InvalidRepoError' + name: err.constructor.name, + path: err.path, + errors: err.errors + }) + } else if (err instanceof InfoValidationError || err instanceof KeymapValidationError) { + return res.status(400).json({ + name: err.name, + errors: err.errors }) } diff --git a/api/services/github/api.js b/api/services/github/api.js index bfaad61..d41b475 100644 --- a/api/services/github/api.js +++ b/api/services/github/api.js @@ -22,8 +22,11 @@ async function request (options={}) { } const response = await axios(options) + const limitRemaining = response.headers['x-ratelimit-remaining'] - console.log(response.headers['x-ratelimit-remaining']) + if (limitRemaining) { + console.log('GitHub API ratelimit remaining requests:', limitRemaining) + } return response } diff --git a/api/services/github/files.js b/api/services/github/files.js index ddfecf8..09214b1 100644 --- a/api/services/github/files.js +++ b/api/services/github/files.js @@ -4,25 +4,24 @@ const zmk = require('../zmk') const MODE_FILE = '100644' -class InvalidRepoError extends Error {} +class MissingRepoFile extends Error { + constructor(path) { + super() + this.name = 'MissingRepoFile' + this.path = path + this.errors = [`Missing file ${path}`] + } +} async function fetchKeyboardFiles (installationId, repository, branch) { const { data: { token: installationToken } } = await auth.createInstallationToken(installationId) - try { - const { data: info } = await fetchFile(installationToken, repository, 'config/info.json', { raw: true, branch }) - const { data: keymap } = await fetchFile(installationToken, repository, 'config/keymap.json', { raw: true, branch }) - - return { info, keymap } - } catch (err) { - if (err.response && err.response.status === 404) { - throw new InvalidRepoError() - } + const { data: info } = await fetchFile(installationToken, repository, 'config/info.json', { raw: true, branch }) + const { data: keymap } = await fetchFile(installationToken, repository, 'config/keymap.json', { raw: true, branch }) - throw err - } + return { info, keymap } } -function fetchFile (installationToken, repository, path, options = {}) { +async function fetchFile (installationToken, repository, path, options = {}) { const { raw = false, branch = null } = options const url = `/repos/${repository}/contents/${path}` const params = {} @@ -32,7 +31,13 @@ function fetchFile (installationToken, repository, path, options = {}) { } const headers = { Accept: raw ? 'application/vnd.github.v3.raw' : 'application/json' } - return api.request({ url, headers, params, token: installationToken }) + try { + return await api.request({ url, headers, params, token: installationToken }) + } catch (err) { + if (err.response?.status === 404) { + throw new MissingRepoFile(path) + } + } } async function commitChanges (installationId, repository, branch, layout, keymap) { @@ -92,7 +97,7 @@ async function commitChanges (installationId, repository, branch, layout, keymap } module.exports = { - InvalidRepoError, + MissingRepoFile, fetchKeyboardFiles, commitChanges } diff --git a/api/services/github/installations.js b/api/services/github/installations.js index fad5468..b00e82d 100644 --- a/api/services/github/installations.js +++ b/api/services/github/installations.js @@ -3,15 +3,25 @@ const linkHeader = require('http-link-header') const api = require('./api') const { createAppToken } = require('./auth') -function fetchInstallation (user) { +async function fetchInstallation (user) { const token = createAppToken() - return api.request({ url: `/users/${user}/installation`, token }).catch(err => { + try { + const response = await api.request({ url: `/users/${user}/installation`, token }) + const { data } = response + if (data.suspended_at) { + console.log(`User ${user} has suspended app installation.`) + return { data: null } + } + + return response + } catch(err) { if (err.response && err.response.status === 404) { + console.log(`User ${user} does not have app installation.`) return { data: null } } throw err - }) + } } async function fetchInstallationRepos (installationToken, installationId) { @@ -20,7 +30,6 @@ async function fetchInstallationRepos (installationToken, installationId) { let url = initialPage while (url) { - console.log('fetching page', url) const { headers, data } = await api.request({ url, token: installationToken }) const paging = linkHeader.parse(headers.link || '') repositories.push(...data.repositories) diff --git a/api/services/zmk/keymap.js b/api/services/zmk/keymap.js index a8837ea..4eafb88 100644 --- a/api/services/zmk/keymap.js +++ b/api/services/zmk/keymap.js @@ -9,6 +9,14 @@ const uniq = require('lodash/uniq') const { renderTable } = require('./layout') +class KeymapValidationError extends Error { + constructor (errors) { + super() + this.name = 'KeymapValidationError' + this.errors = errors + } +} + const behaviours = JSON.parse(fs.readFileSync(path.join(__dirname, 'data/zmk-behaviors.json'))) const behavioursByBind = keyBy(behaviours, 'code') @@ -145,8 +153,48 @@ function generateKeymapJSON (layout, keymap, encoded) { return base.replace('"layers": null', `"layers": [\n ${layers.join(', ')}\n ]`) } +function validateKeymapJson(keymap) { + const errors = [] + + if (typeof keymap !== 'object' || keymap === null) { + errors.push('keymap.json root must be an object') + } else if (!Array.isArray(keymap.layers)) { + errors.push('keymap must include "layers" array') + } else { + for (let i in keymap.layers) { + const layer = keymap.layers[i] + + if (!Array.isArray(layer)) { + errors.push(`Layer at layers[${i}] must be an array`) + } else { + for (let j in layer) { + const key = layer[j] + const keyPath = `layers[${i}][${j}]` + + if (typeof key !== 'string') { + errors.push(`Value at "${keyPath}" must be a string`) + } else { + const bind = key.match(/^&.+?\b/) + if (!(bind && bind[0] in behavioursByBind)) { + errors.push(`Key bind at "${keyPath}" has invalid behaviour`) + } + } + + // TODO: validate remaining bind parameters + } + } + } + } + + if (errors.length) { + throw new KeymapValidationError(errors) + } +} + module.exports = { + KeymapValidationError, encodeKeymap, parseKeymap, - generateKeymap + generateKeymap, + validateKeymapJson } diff --git a/api/services/zmk/layout.js b/api/services/zmk/layout.js index b3c9d52..0e6ff96 100644 --- a/api/services/zmk/layout.js +++ b/api/services/zmk/layout.js @@ -1,3 +1,13 @@ +const isNumber = require('lodash/isNumber') + +class InfoValidationError extends Error { + constructor (errors) { + super() + this.name = 'InfoValidationError' + this.errors = errors + } +} + function renderTable (layout, layer, opts={}) { const { useQuotes = false, @@ -39,4 +49,57 @@ function renderTable (layout, layer, opts={}) { }).join('\n') } -module.exports = { renderTable } +function validateInfoJson(info) { + const errors = [] + + if (typeof info !== 'object' || info === null) { + errors.push('info.json root must be an object') + } else if (!info.layouts) { + errors.push('info must define "layouts"') + } else if (typeof info.layouts !== 'object' || info.layouts === null) { + errors.push('layouts must be an object') + } else if (Object.values(info.layouts).length === 0) { + errors.push('layouts must define at least one layout') + } else { + for (let name in info.layouts) { + const layout = info.layouts[name] + if (typeof layout !== 'object' || layout === null) { + errors.push(`layout ${name} must be an object`) + } else if (!Array.isArray(layout.layout)) { + errors.push(`layout ${name} must define "layout" array`) + } else { + for (let i in layout.layout) { + const key = layout.layout[i] + const keyPath = `layouts[${name}].layout[${i}]` + + if (typeof key !== 'object' || key === null) { + errors.push(`Key definition at ${keyPath} must be an object`) + } else { + const optionalNumberProps = ['u', 'h', 'r', 'rx', 'ry'] + if (!isNumber(key.x)) { + errors.push(`Key definition at ${keyPath} must include "x" position`) + } + if (!isNumber(key.y)) { + errors.push(`Key definition at ${keyPath} must include "y" position`) + } + for (let prop of optionalNumberProps) { + if (prop in key && !isNumber(key[prop])) { + errors.push(`Key definition at ${keyPath} optional "${prop}" must be number`) + } + } + } + } + } + } + } + + if (errors.length) { + throw new InfoValidationError(errors) + } +} + +module.exports = { + InfoValidationError, + renderTable, + validateInfoJson +} |