aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--api/routes/github.js17
-rw-r--r--api/services/github/api.js5
-rw-r--r--api/services/github/files.js35
-rw-r--r--api/services/github/installations.js17
-rw-r--r--api/services/zmk/keymap.js50
-rw-r--r--api/services/zmk/layout.js65
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
+}