1033 строки
30 KiB
JavaScript
Executable File
1033 строки
30 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
|
|
|
// This script generates API documentation. By default it writes output
|
|
// to `docs/api.md`, but you can override that by specifying a different
|
|
// path as an argument. Requires node 6 or greater.
|
|
|
|
'use strict'
|
|
|
|
const acorn = require('acorn')
|
|
const P = require('../lib/promise')
|
|
const fs = P.promisifyAll(require('fs'), { suffix: 'P' })
|
|
const path = require('path')
|
|
const handlebars = require('handlebars')
|
|
const render = handlebars.compile(
|
|
fs.readFileSync(path.resolve(__dirname, 'api-docs.handlebars'), { encoding: 'utf8' })
|
|
)
|
|
|
|
// In the API docs, any text inside <!--begin-xxx--> / <!--end-xxx-> delimiters
|
|
// will be propagated to the same place each time the docs are regenerated.
|
|
const DOCS_DATA = /<!--begin-([a-z_]+(?:-[a-z_]+)*)-->((?:.*?\n?.*?)*?)<!--end-([a-z_]+(?:-[a-z_]+)*)-->/gi
|
|
const ACORN_OPTIONS = {
|
|
locations: true,
|
|
sourceType: 'script'
|
|
}
|
|
const EMPTY_MAP = new Map()
|
|
const IGNORE = new Set([ 'defaults.js', 'idp.js', 'index.js', 'validators.js' ])
|
|
// HACK: Assumes the location of lib/routes/*.js
|
|
const ROUTES_DIR = path.resolve(__dirname, '../lib/routes')
|
|
const FUNCTION_EXPRESSION_TYPES = new Set([ 'FunctionExpression', 'ArrowFunctionExpression' ])
|
|
const ARRAY_TYPES = new Set([ 'ArrayExpression' ])
|
|
const RETURN_TYPES = new Set([ 'ReturnStatement' ])
|
|
const OBJECT_TYPES = new Set([ 'ObjectExpression' ])
|
|
const LITERAL_TYPES = new Set([ 'Literal' ])
|
|
const CALL_TYPES = new Set([ 'CallExpression' ])
|
|
const MEMBER_TYPES = new Set([ 'MemberExpression' ])
|
|
const ERROR_PROPERTY_TYPES = new Set([ 'Literal', 'MemberExpression', 'BinaryExpression', 'LogicalExpression' ])
|
|
const SESSION_TOKEN_STRATEGY = /^sessionToken/
|
|
const KEY_FETCH_TOKEN_STRATEGY = /^keyFetchToken/
|
|
const NOT_ERRORS = new Set([ 'toString', 'header', 'backtrace', 'translate' ])
|
|
|
|
|
|
function main() {
|
|
const args = parseArgs()
|
|
doc(args)
|
|
.catch(err => {
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main()
|
|
} else {
|
|
module.exports = doc
|
|
}
|
|
|
|
function doc(args) {
|
|
return P.all([
|
|
parseDocs(args.path),
|
|
parseErrors()
|
|
])
|
|
.spread((docs, errors) => P.all([
|
|
parseRoutes().then(routes => marshallRouteData(docs, errors.definitionsMap, routes)),
|
|
parseValidators(),
|
|
parseMetricsContext(),
|
|
parseFeatures(),
|
|
parseDevices(),
|
|
docs,
|
|
errors
|
|
]))
|
|
.spread((modules, validators, metricsContext, features, devices, docs, errors) =>
|
|
writeOutput(Object.assign({
|
|
modules,
|
|
validators,
|
|
metricsContext,
|
|
features,
|
|
devices,
|
|
errors: errors.definitions,
|
|
additionalErrorParams: errors.additionalErrorParams
|
|
}, docs), args.path)
|
|
)
|
|
.then(() => {
|
|
console.log(`API docs updated at ${args.path}`)
|
|
})
|
|
.catch(error => {
|
|
console.error(error.stack)
|
|
})
|
|
|
|
}
|
|
|
|
function parseArgs () {
|
|
let outputPath
|
|
|
|
switch (process.argv.length) {
|
|
/* eslint-disable indent, no-fallthrough */
|
|
case 3:
|
|
outputPath = path.resolve(process.argv[2])
|
|
case 2:
|
|
break
|
|
default:
|
|
fail(`Usage: ${process.argv[1]} [outputPath]`)
|
|
/* eslint-enable indent, no-fallthrough */
|
|
}
|
|
|
|
return {
|
|
path: outputPath || path.resolve(__dirname, '../docs/api.md')
|
|
}
|
|
}
|
|
|
|
function parseDocs (docsPath) {
|
|
return fs.readFileP(docsPath, { encoding: 'utf8' })
|
|
.then(docs => parseDocSections(docs, docsPath, {}))
|
|
}
|
|
|
|
function parseDocSections (docs, docsPath, data) {
|
|
const match = DOCS_DATA.exec(docs)
|
|
if (! match) {
|
|
return data
|
|
}
|
|
|
|
if (match.length !== 4) {
|
|
fail(`Match insanity, length ${match.length} !== 4`, docsPath)
|
|
}
|
|
|
|
const beginTag = match[1], endTag = match[3]
|
|
if (beginTag !== endTag) {
|
|
fail(`Unmatched delimiters, ${beginTag} !== ${endTag}`, docsPath)
|
|
}
|
|
|
|
data[camel(beginTag)] = match[2].trim()
|
|
|
|
return parseDocSections(docs, docsPath, data)
|
|
}
|
|
|
|
function camel (string) {
|
|
return string.replace(/-[a-z]/g, lowercase => lowercase.substr(1).toUpperCase())
|
|
}
|
|
|
|
function fail (message, filePath, lineNumber) {
|
|
let debugFriendlyMessage
|
|
if (filePath) {
|
|
debugFriendlyMessage = `Error parsing "${filePath}"`
|
|
if (lineNumber) {
|
|
debugFriendlyMessage += ` at line ${lineNumber}`
|
|
}
|
|
debugFriendlyMessage += `:\n${message}`
|
|
} else {
|
|
debugFriendlyMessage = message
|
|
}
|
|
|
|
throw new TypeError(debugFriendlyMessage)
|
|
}
|
|
|
|
function parseRoutes () {
|
|
return fs.readdirP(ROUTES_DIR)
|
|
.then(fileNames => {
|
|
return P.all(
|
|
fileNames
|
|
.filter(fileName => fileName.endsWith('.js') && ! IGNORE.has(fileName))
|
|
.map(fileName => path.join(ROUTES_DIR, fileName))
|
|
.filter(filePath => fs.statSync(filePath).isFile())
|
|
.map(filePath => {
|
|
return fs.readFileP(filePath, { encoding: 'utf8' })
|
|
.then(js => ({
|
|
path: filePath,
|
|
node: acorn.parse(js, ACORN_OPTIONS).body
|
|
}))
|
|
})
|
|
)
|
|
})
|
|
}
|
|
|
|
function marshallRouteData (docs, errors, files) {
|
|
return files.map(file => {
|
|
const filePath = file.path
|
|
const node = file.node
|
|
const moduleName = getModuleName(filePath)
|
|
const variables = parseVariables(node)
|
|
const exportedFunction = findExportedFunction(node, filePath)
|
|
const routes = findReturnedData(exportedFunction, filePath)
|
|
|
|
return {
|
|
moduleName,
|
|
slug: getSlug(moduleName),
|
|
routes: routes.map(route => {
|
|
assertType(route, OBJECT_TYPES, filePath)
|
|
|
|
const method = findRouteMethod(route, filePath)
|
|
const routePath = findRoutePath(route, filePath)
|
|
const slug = getSlug(`${method} ${routePath}`)
|
|
const tag = `route-${slug}`
|
|
const config = findRouteConfig(route, filePath)
|
|
let authentication, validation, response
|
|
if (config) {
|
|
authentication = findRouteAuthentication(config, filePath)
|
|
validation = findRouteValidation(config, filePath)
|
|
response = findRouteResponse(config, filePath)
|
|
}
|
|
const queryParameters = marshallQueryParameters(docs, slug, validation, variables, filePath)
|
|
const requestBodyParameters = marshallRequestBodyParameters(docs, slug, validation, variables, filePath)
|
|
const responseBodyParameters = marshallResponseBodyParameters(docs, slug, response, variables, filePath)
|
|
const errorResponses = marshallErrorResponses(errors, route, filePath)
|
|
return {
|
|
method,
|
|
path: routePath,
|
|
slug,
|
|
routeDescription: docs[camel(tag)],
|
|
authentication: marshallAuthentication(authentication),
|
|
hasQueryParameters: queryParameters.length > 0,
|
|
queryParameters: queryParameters,
|
|
hasRequestBody: requestBodyParameters.length > 0,
|
|
requestBody: requestBodyParameters,
|
|
hasResponseBody: responseBodyParameters.length > 0,
|
|
responseBody: responseBodyParameters,
|
|
hasErrorResponses: errorResponses.length > 0,
|
|
errorResponses: errorResponses
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
function getModuleName (filePath) {
|
|
return path.basename(filePath, '.js')
|
|
.replace(/^[a-z]/, character => character.toUpperCase())
|
|
.replace(/-/g, ' ')
|
|
}
|
|
|
|
function parseVariables (node) {
|
|
return findVariables(node)
|
|
.reduce((map, variable) => {
|
|
variable.declarations.forEach(declaration => {
|
|
if (declaration.init) {
|
|
const value = marshallValue(declaration.init, EMPTY_MAP)
|
|
if (value) {
|
|
map.set(declaration.id.name, value)
|
|
}
|
|
}
|
|
})
|
|
return map
|
|
}, new Map())
|
|
}
|
|
|
|
function findVariables (node) {
|
|
return find(node, {
|
|
type: 'VariableDeclaration'
|
|
}, { array: true })
|
|
}
|
|
|
|
function find (node, criteria, options = {}) {
|
|
|
|
if (match(node, criteria)) {
|
|
return [ node ]
|
|
}
|
|
|
|
if (Array.isArray(node) && options.array) {
|
|
return node.reduce((results, property) => {
|
|
return results.concat(find(property, criteria, options))
|
|
}, [])
|
|
}
|
|
|
|
if (isObject(node) && options.recursive) {
|
|
return Object.keys(node).reduce((results, key) => {
|
|
return results.concat(find(node[key], criteria, options))
|
|
}, [])
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
function match (node, criteria) {
|
|
if (! isObject(node)) {
|
|
if (node === criteria) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
if (! isObject(criteria)) {
|
|
return false
|
|
}
|
|
|
|
return Object.keys(criteria).every(criteriaKey => {
|
|
return Object.keys(node).some(nodeKey => {
|
|
return match(node[nodeKey], criteria[criteriaKey])
|
|
})
|
|
})
|
|
}
|
|
|
|
function isObject (node) {
|
|
return node && typeof node === 'object'
|
|
}
|
|
|
|
function marshallValue (node, variables) {
|
|
switch (node.type) {
|
|
/* eslint-disable indent */
|
|
case 'Literal':
|
|
return node.raw
|
|
|
|
case 'Identifier':
|
|
return variables.get(node.name) || node.name
|
|
|
|
case 'CallExpression': {
|
|
let result = marshallValue(node.callee, EMPTY_MAP)
|
|
if (node.arguments.length > 0) {
|
|
result += `(${
|
|
node.arguments.map(argument => marshallValue(argument, variables)).join(', ')
|
|
})`
|
|
}
|
|
return result
|
|
}
|
|
|
|
case 'MemberExpression': {
|
|
const unmapped = marshallValue(node.property, EMPTY_MAP)
|
|
const mapped = marshallValue(node.property, variables)
|
|
if (mapped !== unmapped) {
|
|
// HACK: Assumes there are no variable name conflicts between different scopes
|
|
return mapped
|
|
}
|
|
return `${
|
|
marshallValue(node.object, EMPTY_MAP)
|
|
}.${unmapped}`
|
|
}
|
|
|
|
case 'ObjectExpression': {
|
|
const properties = node.properties.map(
|
|
property => `${property.key.name}: ${
|
|
marshallValue(property.value, variables)
|
|
}`
|
|
).join(', ')
|
|
return `{ ${properties} }`
|
|
}
|
|
/* eslint-enable indent */
|
|
}
|
|
}
|
|
|
|
function findExportedFunction (node, filePath) {
|
|
const exported = findModuleExports(node)
|
|
|
|
if (exported.length !== 1) {
|
|
fail(`Expected 1 export, found ${exported.length}`, filePath)
|
|
}
|
|
|
|
const exportedFunction = exported[0].right
|
|
assertType(exportedFunction, FUNCTION_EXPRESSION_TYPES, filePath)
|
|
|
|
return exportedFunction.body
|
|
}
|
|
|
|
function findModuleExports (node) {
|
|
// HACK: Assumes we always export with `module.exports =` or `exports.foo =`
|
|
return findAssignmentsTo(node, {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: 'module'
|
|
},
|
|
property: {
|
|
type: 'Identifier',
|
|
name: 'exports'
|
|
}
|
|
}
|
|
})
|
|
.concat(findAssignmentsTo(node, {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: 'exports'
|
|
}
|
|
}))
|
|
}
|
|
|
|
function findAssignmentsTo (node, lhs) {
|
|
return find(node, {
|
|
type: 'AssignmentExpression',
|
|
operator: '=',
|
|
left: lhs
|
|
}, { recursive: true })
|
|
}
|
|
|
|
function assertType (node, types, filePath) {
|
|
if (! node) {
|
|
fail(`Expected type [${Array.from(types).join(',')}], found nothing`, filePath)
|
|
}
|
|
|
|
const nodeType = node.type
|
|
|
|
if (! types.has(nodeType)) {
|
|
const line = node.loc.start.line
|
|
const column = node.loc.start.column
|
|
fail(`Expected type [${Array.from(types).join(',')}], found "${nodeType}" at column "${column}"`, filePath, line)
|
|
}
|
|
}
|
|
|
|
function assertName (node, name, filePath) {
|
|
if (! node) {
|
|
fail(`Expected something named ${name}, found nothing`, filePath)
|
|
}
|
|
|
|
if (node.name !== name) {
|
|
const line = node.loc.start.line
|
|
const column = node.loc.start.column
|
|
fail(`Expected name "${name}", found "${node.name}" at column "${column}"`, filePath, line)
|
|
}
|
|
}
|
|
|
|
function findReturnedData (functionNode, filePath) {
|
|
let returnedData
|
|
if (functionNode.type === 'BlockStatement') {
|
|
const returned = find(functionNode.body, {
|
|
type: 'ReturnStatement'
|
|
}, {
|
|
array: true
|
|
})
|
|
|
|
if (returned.length !== 1) {
|
|
fail(`Expected 1 return statement, found ${returned.length}`, filePath)
|
|
}
|
|
|
|
returnedData = returned[0].argument
|
|
} else {
|
|
assertType(returnedData, RETURN_TYPES, filePath)
|
|
returnedData = functionNode.argument
|
|
}
|
|
|
|
if (returnedData.type === 'Identifier') {
|
|
// HACK: Assumes there are no variable name conflicts in nested scopes
|
|
const routeDefinitions = find(functionNode, {
|
|
type: 'VariableDeclarator',
|
|
id: {
|
|
type: 'Identifier',
|
|
name: returnedData.name
|
|
}
|
|
}, {
|
|
recursive: true
|
|
})
|
|
|
|
if (routeDefinitions.length !== 1) {
|
|
fail(`Expected 1 set of route definitions, found ${routeDefinitions.length}`, filePath)
|
|
}
|
|
|
|
returnedData = routeDefinitions[0].init
|
|
}
|
|
|
|
assertType(returnedData, ARRAY_TYPES, filePath)
|
|
|
|
return returnedData.elements
|
|
}
|
|
|
|
function findRoutePath (route, filePath) {
|
|
return findProperty(route, 'path', LITERAL_TYPES, filePath).value
|
|
}
|
|
|
|
function findProperty (node, key, types, filePath) {
|
|
const found = find(node.properties, {
|
|
type: 'Property',
|
|
kind: 'init',
|
|
key: {
|
|
type: 'Identifier',
|
|
name: key
|
|
}
|
|
}, {
|
|
array: true
|
|
})[0]
|
|
|
|
if (found) {
|
|
assertType(found.value, types, filePath)
|
|
|
|
return found.value
|
|
}
|
|
}
|
|
|
|
function findRouteMethod (route, filePath) {
|
|
return findProperty(route, 'method', LITERAL_TYPES, filePath).value
|
|
}
|
|
|
|
function findRouteConfig (route, filePath) {
|
|
return findProperty(route, 'config', OBJECT_TYPES, filePath)
|
|
}
|
|
|
|
function findRouteAuthentication (config, filePath) {
|
|
const authentication = findProperty(config, 'auth', OBJECT_TYPES, filePath)
|
|
if (authentication) {
|
|
let optional = false, tokens
|
|
|
|
const mode = findProperty(authentication, 'mode', LITERAL_TYPES, filePath)
|
|
if (mode && (mode.value === 'try' || mode.value === 'optional')) {
|
|
optional = true
|
|
}
|
|
|
|
const strategies = findProperty(authentication, 'strategies', ARRAY_TYPES, filePath)
|
|
if (strategies) {
|
|
tokens = strategies.elements.map(strategy => {
|
|
assertType(strategy, LITERAL_TYPES, filePath)
|
|
return strategy.value
|
|
})
|
|
} else {
|
|
const strategy = findProperty(authentication, 'strategy', LITERAL_TYPES, filePath)
|
|
if (strategy) {
|
|
tokens = [ strategy.value ]
|
|
}
|
|
}
|
|
|
|
if (! tokens) {
|
|
fail('Missing authentication strategy', filePath, authentication.loc.start.line)
|
|
}
|
|
|
|
return { optional, tokens }
|
|
}
|
|
}
|
|
|
|
function findRouteValidation (config, filePath) {
|
|
return findProperty(config, 'validate', OBJECT_TYPES, filePath)
|
|
}
|
|
|
|
function findRouteResponse (config, filePath) {
|
|
return findProperty(config, 'response', OBJECT_TYPES, filePath)
|
|
}
|
|
|
|
function marshallQueryParameters (docs, routeSlug, validation, variables, filePath) {
|
|
return marshallParameters(docs, 'query-param', routeSlug, validation, 'query', variables, filePath)
|
|
}
|
|
|
|
function marshallParameters (docs, paramTag, routeSlug, node, type, variables, filePath) {
|
|
let parameters
|
|
|
|
try {
|
|
parameters = findProperty(node, type, OBJECT_TYPES, filePath)
|
|
} catch (error) {
|
|
return marshallParameterCall(docs, paramTag, routeSlug, node, type, variables, filePath)
|
|
}
|
|
|
|
if (! parameters) {
|
|
return []
|
|
}
|
|
|
|
return marshallParameterProperties(docs, paramTag, routeSlug, parameters, variables)
|
|
}
|
|
|
|
function marshallParameterCall (docs, paramTag, routeSlug, node, type, variables, filePath) {
|
|
try {
|
|
const parameters = findProperty(node, type, CALL_TYPES, filePath)
|
|
assertType(parameters.callee, MEMBER_TYPES, filePath)
|
|
assertType(parameters.callee.object, CALL_TYPES, filePath)
|
|
assertType(parameters.callee.object.callee, MEMBER_TYPES, filePath)
|
|
|
|
if (
|
|
parameters.callee.object.callee.property.name === 'alternatives' &&
|
|
parameters.callee.property.name === 'try'
|
|
) {
|
|
return marshallParameterArguments(docs, paramTag, routeSlug, parameters, variables, filePath)
|
|
}
|
|
|
|
if (
|
|
parameters.callee.object.callee.property.name === 'array' &&
|
|
parameters.callee.property.name === 'items'
|
|
) {
|
|
return marshallParameterArguments(docs, paramTag, routeSlug, parameters, variables, filePath)
|
|
}
|
|
|
|
if (
|
|
parameters.callee.object.callee.object.name === 'isA' &&
|
|
parameters.callee.object.callee.property.name === 'object'
|
|
) {
|
|
return marshallParameterProperties(docs, paramTag, routeSlug, parameters.callee.object.arguments[0], variables)
|
|
}
|
|
} catch (error) {
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
function marshallParameterArguments (docs, paramTag, routeSlug, parameters, variables, filePath) {
|
|
const invertedIndex = new Map()
|
|
|
|
return parameters.arguments.reduce((result, arg) => {
|
|
switch (arg.type) {
|
|
/* eslint-disable indent */
|
|
case 'ObjectExpression':
|
|
merge(result, marshallParameterProperties(docs, paramTag, routeSlug, parameters, variables))
|
|
break
|
|
case 'CallExpression': {
|
|
// HACK: Assumes we only have isA.object inside isA.alternatives and array.items
|
|
const call = find(arg, {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: 'isA',
|
|
},
|
|
property: {
|
|
type: 'Identifier',
|
|
name: 'object'
|
|
}
|
|
}
|
|
}, { recursive: true })
|
|
if (! call || call.length === 0) {
|
|
throw null
|
|
}
|
|
call[0].arguments.forEach(arg => {
|
|
assertType(arg, OBJECT_TYPES, filePath)
|
|
merge(result, marshallParameterProperties(docs, paramTag, routeSlug, arg, variables))
|
|
})
|
|
break
|
|
}
|
|
default:
|
|
throw null
|
|
/* eslint-enable indent */
|
|
}
|
|
|
|
return result
|
|
}, [])
|
|
|
|
function merge (target, source) {
|
|
source.forEach(item => {
|
|
const paramName = item.paramName
|
|
if (invertedIndex.has(paramName)) {
|
|
const resultIndex = invertedIndex.get(paramName)
|
|
if (target[resultIndex].validation !== item.validation) {
|
|
target[resultIndex].validation += `;<br />or ${item.validation}`
|
|
}
|
|
} else {
|
|
invertedIndex.set(paramName, target.length)
|
|
target.push(item)
|
|
}
|
|
})
|
|
|
|
return target
|
|
}
|
|
}
|
|
|
|
function marshallParameterProperties (docs, paramTag, routeSlug, parameters, variables) {
|
|
return parameters.properties.map(parameter => ({
|
|
paramName: parameter.key.name,
|
|
paramDescription: docs[camel(`${paramTag}-${routeSlug}-${parameter.key.name}`)],
|
|
validation: `*${marshallValidation(marshallValue(parameter.value, variables))}*`
|
|
}))
|
|
}
|
|
|
|
function marshallRequestBodyParameters (docs, routeSlug, validation, variables, filePath) {
|
|
return marshallParameters(docs, 'request-body', routeSlug, validation, 'payload', variables, filePath)
|
|
}
|
|
|
|
function marshallResponseBodyParameters (docs, routeSlug, response, variables, filePath) {
|
|
return marshallParameters(docs, 'response-body', routeSlug, response, 'schema', variables, filePath)
|
|
}
|
|
|
|
function findRouteHandler (route, filePath) {
|
|
try {
|
|
// HACK: Assumes route handlers are defined inline in the object literal
|
|
return findProperty(route, 'handler', FUNCTION_EXPRESSION_TYPES, filePath)
|
|
} catch (error) {
|
|
}
|
|
}
|
|
|
|
function marshallErrorResponses (errors, route, filePath) {
|
|
const handler = findRouteHandler(route, filePath)
|
|
let extras = findProperty(route, 'apidoc', OBJECT_TYPES, filePath)
|
|
if (extras) {
|
|
extras = findProperty(extras, 'errors', ARRAY_TYPES, filePath).elements
|
|
} else {
|
|
extras = []
|
|
}
|
|
const dupes = new Set()
|
|
// HACK: Assumes we always import error module as `error`
|
|
return find(handler, {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: 'error'
|
|
}
|
|
}
|
|
}, { recursive: true })
|
|
.map(errorCall => {
|
|
return errorCall.callee.property.name
|
|
})
|
|
.concat(extras.map(expr => {
|
|
assertName(expr.object, 'error', filePath)
|
|
return expr.property.name
|
|
}))
|
|
.filter(errorName => {
|
|
if (dupes.has(errorName)) {
|
|
return false
|
|
}
|
|
dupes.add(errorName)
|
|
return !! errors[errorName]
|
|
})
|
|
.map(errorName => errors[errorName])
|
|
.sort((err1, err2) => {
|
|
return (err1.code - err2.code) || (err1.errno - err2.errno)
|
|
})
|
|
}
|
|
|
|
function marshallAuthentication (authentication) {
|
|
if (! authentication) {
|
|
return
|
|
}
|
|
|
|
const tokens = authentication.tokens.map(token => {
|
|
return marshallToken(token)
|
|
}).reduce((deduped, token) => {
|
|
if (deduped.indexOf(token) === -1) {
|
|
deduped.push(token)
|
|
}
|
|
return deduped
|
|
}, [])
|
|
|
|
return {
|
|
emojis: `:lock:${authentication.optional ? ':unlock:' : ''}`,
|
|
token: tokens.join(', '),
|
|
summary: tokens.sort((lhs, rhs) => {
|
|
// Move OAuth tokens to the front of the list as a concession to readability
|
|
if (lhs === 'oauthToken') {
|
|
return -1
|
|
}
|
|
if (rhs === 'oauthToken') {
|
|
return 1
|
|
}
|
|
return 0
|
|
}).reduce((summary, token, index) => {
|
|
if (token === 'oauthToken') {
|
|
summary += 'authenticated with OAuth bearer token'
|
|
} else {
|
|
summary += `${index === 0 ? '' : ', or '}HAWK-authenticated with ${uncamel(token)}`
|
|
}
|
|
return summary
|
|
}, authentication.optional ? 'Optionally ' : '')
|
|
}
|
|
}
|
|
|
|
function marshallToken (token) {
|
|
if (SESSION_TOKEN_STRATEGY.test(token)) {
|
|
return 'sessionToken'
|
|
}
|
|
|
|
if (KEY_FETCH_TOKEN_STRATEGY.test(token)) {
|
|
return 'keyFetchToken'
|
|
}
|
|
|
|
// HACK: Assumes other tokens don't have extra authentication strategies
|
|
return token
|
|
}
|
|
|
|
function uncamel (string) {
|
|
return string.replace(/[A-Z]/g, uppercase => ` ${uppercase.toLowerCase()}`)
|
|
}
|
|
|
|
function getSlug (string) {
|
|
return string.toLowerCase().replace(/\s/g, '-').replace(/[^a-z0-9_-]/g, '')
|
|
}
|
|
|
|
function parseValidators () {
|
|
// HACK: Assumes the location of lib/routes/validators.js
|
|
return parseModuleExports('../lib/routes/validators')
|
|
.then(validators => validators.map(item => {
|
|
item.value = marshallValidation(item.value)
|
|
return item
|
|
}))
|
|
}
|
|
|
|
function parseModuleExports (relativePath) {
|
|
return parseModule(relativePath)
|
|
.then(node => {
|
|
const variables = parseVariables(node)
|
|
return findModuleExports(node)
|
|
.map(moduleExports => ({
|
|
key: moduleExports.left.property.name,
|
|
value: marshallValue(moduleExports.right, variables)
|
|
}))
|
|
.filter(moduleExports => !! moduleExports.value)
|
|
})
|
|
}
|
|
|
|
function parseModule (relativePath) {
|
|
return fs.readFileP(`${path.resolve(__dirname, relativePath)}.js`, { encoding: 'utf8' })
|
|
.then(js => acorn.parse(js, ACORN_OPTIONS).body)
|
|
}
|
|
|
|
function marshallValidation (validation) {
|
|
if (typeof validation !== 'string') {
|
|
return validation
|
|
}
|
|
|
|
// HACK: Assumes single quotes, specific paths
|
|
validation = validation.replace(/require\('\.\.\/metrics\/context'\)/g, 'metricsContext')
|
|
validation = validation.replace(/require\('\.\.\/features'\)/g, 'features')
|
|
validation = validation.replace(/require\('\.\.\/devices'\)/g, 'devices')
|
|
|
|
// HACK: Assumes we always import joi as `isA`
|
|
if (! /^isA\./.test(validation)) {
|
|
return validation
|
|
}
|
|
|
|
// HACK: Assumes joi methods always begin and end with a lower-case letter
|
|
return validation.replace(/isA\./g, '').replace(/([a-z)])\.([a-z])/g, '$1, $2')
|
|
}
|
|
|
|
function parseMetricsContext () {
|
|
// HACK: Assumes the location of lib/metrics/context.js
|
|
return parseModuleExports('../lib/metrics/context')
|
|
.then(metricsContext => metricsContext.map(item => {
|
|
item.value = marshallValidation(
|
|
item.value
|
|
.replace('{ ', '{\n * ')
|
|
.replace(/ }/, '\n\n }')
|
|
.replace(/, ([a-zA-Z]+):/g, '\n * $1:')
|
|
.replace(/([a-zA-Z]+):/g, '`$1`:')
|
|
)
|
|
return item
|
|
}))
|
|
}
|
|
|
|
function parseFeatures () {
|
|
// HACK: Assumes the location of lib/features.js
|
|
return parseModuleExports('../lib/features')
|
|
.then(features => features.map(item => {
|
|
item.value = marshallValidation(item.value)
|
|
return item
|
|
}))
|
|
}
|
|
|
|
function parseDevices () {
|
|
// HACK: Assumes the location of lib/devices.js
|
|
return parseModuleExports('../lib/devices')
|
|
.then(devices => devices.map(item => {
|
|
let nesting = 0
|
|
item.value = marshallValidation(
|
|
item.value
|
|
.replace('{ ', '{\n * `')
|
|
.replace(/ }$/, '\n\n }')
|
|
.replace(/isA\.object\({ ([a-zA-Z]+):/g, 'isA.object({\n * `$1`:')
|
|
.replace(/, /g, '\n * `')
|
|
.replace(/`([a-zA-Z]+):/g, '`$1`:')
|
|
.split('\n')
|
|
.map(line => {
|
|
line = line.replace(/ +\*/, ` ${' '.repeat(nesting)}*`)
|
|
if (line.indexOf('isA.object({') >= 0) {
|
|
nesting += 1
|
|
} else if (line.endsWith(' })')) {
|
|
line = line.replace(/ }\)$/, `\n ${' '.repeat(nesting)}* })`)
|
|
nesting -= 1
|
|
}
|
|
return line
|
|
})
|
|
.join('\n')
|
|
)
|
|
return item
|
|
}))
|
|
}
|
|
|
|
function parseErrors () {
|
|
// HACK: Assumes the location of lib/error.js
|
|
return parseModule('../lib/error')
|
|
.then(node => {
|
|
const declarations = findVariables(node)
|
|
.reduce((variables, variable) => variables.concat(variable.declarations), [])
|
|
const errno = filterDeclarations(declarations, 'ERRNO')
|
|
const defaults = filterDeclarations(declarations, 'DEFAULTS')
|
|
|
|
assertType(errno, OBJECT_TYPES, 'lib/error.js')
|
|
assertType(defaults, OBJECT_TYPES, 'lib/error.js')
|
|
|
|
const errnoMap = parseObject(errno, EMPTY_MAP)
|
|
const defaultsMap = parseObject(defaults, errnoMap)
|
|
|
|
const result = findAppErrors(node)
|
|
.reduce(
|
|
marshallErrors.bind(null, errnoMap, defaultsMap),
|
|
{ errors: [], definitionsMap: {}, additionalErrorParams: [] }
|
|
)
|
|
|
|
return {
|
|
definitions: result.errors.sort((lhs, rhs) => lhs.errno - rhs.errno),
|
|
definitionsMap: result.definitionsMap,
|
|
additionalErrorParams: result.additionalErrorParams.sort((lhs, rhs) => lhs.errno - rhs.errno)
|
|
}
|
|
})
|
|
}
|
|
|
|
function filterDeclarations (declarations, name) {
|
|
return declarations.filter(
|
|
declaration => declaration.init && declaration.id.name === name
|
|
)[0].init
|
|
}
|
|
|
|
function parseObject (object, variables) {
|
|
return new Map(object.properties.map(property => [
|
|
property.key.name, marshallValue(property.value, variables)
|
|
]))
|
|
}
|
|
|
|
function findAppErrors (node) {
|
|
// HACK: Assumes the error object is called AppError and that we always
|
|
// assign error functions to its properties, rather than define it
|
|
// as e.g. an object literal.
|
|
return findAssignmentsTo(node, {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: 'AppError'
|
|
}
|
|
}).filter(assignment => ! NOT_ERRORS.has(assignment.left.property.name))
|
|
}
|
|
|
|
function marshallErrors (errnoMap, defaultsMap, result, errorFunction) {
|
|
// HACK: Assumes the error object is called AppError
|
|
const returns = find(errorFunction, {
|
|
type: 'ReturnStatement',
|
|
argument: {
|
|
type: 'NewExpression',
|
|
callee: {
|
|
type: 'Identifier',
|
|
name: 'AppError'
|
|
}
|
|
}
|
|
}, { recursive: true })
|
|
|
|
returns.forEach(r => {
|
|
let code, errno, message
|
|
const args = r.argument.arguments
|
|
if (args) {
|
|
const error = args[0]
|
|
assertType(error, OBJECT_TYPES, 'lib/error.js')
|
|
code = marshallErrorProperty(error, 'code')
|
|
errno = marshallErrorProperty(error, 'errno', errnoMap)
|
|
message = marshallErrorProperty(error, 'message')
|
|
|
|
if (args.length > 1) {
|
|
const extraData = args[1]
|
|
let params
|
|
switch (extraData.type) {
|
|
/* eslint-disable indent */
|
|
case 'Identifier': {
|
|
let assignment
|
|
const variables = find(errorFunction, {
|
|
type: 'VariableDeclaration'
|
|
}, { recursive: true })
|
|
variables.some(variable => {
|
|
return variable.declarations.some(declaration => {
|
|
if (declaration.id.name === extraData.name) {
|
|
assignment = declaration.init
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
})
|
|
if (! assignment) {
|
|
const assignments = findAssignmentsTo(errorFunction, {
|
|
type: 'Identifier',
|
|
name: extraData.name
|
|
})
|
|
if (assignments.length > 0) {
|
|
assignment = assignments[0].right
|
|
}
|
|
}
|
|
if (assignment) {
|
|
assertType(assignment, OBJECT_TYPES, 'lib/error.js')
|
|
params = assignment.properties.concat(
|
|
findAssignmentsTo(errorFunction, {
|
|
type: 'MemberExpression',
|
|
object: {
|
|
type: 'Identifier',
|
|
name: extraData.name
|
|
}
|
|
}).map(assignmentTo => ({ key: assignmentTo.left.property }))
|
|
)
|
|
}
|
|
break
|
|
}
|
|
case 'ObjectExpression':
|
|
params = extraData.properties
|
|
/* eslint-enable indent */
|
|
}
|
|
result.additionalErrorParams.push({
|
|
errno: errno || defaultsMap.get('errno'),
|
|
hasParams: params && params.length > 0,
|
|
params: params && params.map(arg => arg.key.name).join(', ')
|
|
})
|
|
}
|
|
}
|
|
|
|
// HACK: Assumes the names of code, errno and message
|
|
result.errors.push({
|
|
code: code || defaultsMap.get('code'),
|
|
errno: errno || defaultsMap.get('errno'),
|
|
definition: message || defaultsMap.get('message').replace(/'/g, '')
|
|
})
|
|
result.definitionsMap[errorFunction.left.property.name] = result.errors[result.errors.length - 1]
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
function marshallErrorProperty (node, name, errnoMap) {
|
|
const property = findProperty(node, name, ERROR_PROPERTY_TYPES, 'lib/error.js')
|
|
|
|
if (property) {
|
|
switch (property.type) {
|
|
/* eslint-disable indent */
|
|
case 'Literal':
|
|
return property.value
|
|
|
|
case 'BinaryExpression':
|
|
// HACK: Assumes we're always interested in the lhs
|
|
return property.left.value
|
|
|
|
case 'LogicalExpression':
|
|
// HACK: Assumes we're always interested in the rhs
|
|
return property.right.value
|
|
|
|
case 'MemberExpression':
|
|
if (errnoMap && property.object.name === 'ERRNO') {
|
|
return errnoMap.get(property.property.name)
|
|
}
|
|
/* eslint-enable indent */
|
|
}
|
|
}
|
|
}
|
|
|
|
function writeOutput (data, outputPath) {
|
|
fs.writeFileSync(outputPath, render(data), { mode: 0o644 })
|
|
}
|
|
|