From 90761acaa4deb9a5eb360f96f38438ad4866fe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20D=C3=ADaz?= Date: Tue, 1 May 2018 22:20:14 -0600 Subject: [PATCH] Added a schema validator. (#13) * Added a schema validator. This allows you to check if the given beau config is valid. Will be used to improve the CLI and remove schema validation-type errors from the actual code. * Added a validate command to the CLI. This command is a way to test the config file. Eventually it'll probably be removed and the schema validation will run on every other command. I have to determine how useful this is and how performance might be affected. --- bin/cli/base.js | 8 +- bin/cli/commands/validate.js | 34 ++++++++ examples/httpbin.yml | 19 +++++ examples/plugins.yml | 2 +- package-lock.json | 47 +++++++++++ package.json | 1 + src/__tests__/__snapshots__/beau.spec.js.snap | 6 -- .../__snapshots__/config.spec.js.snap | 6 -- src/__tests__/config.spec.js | 2 - src/config.js | 1 - src/requestCache.js | 2 +- src/schema.js | 79 +++++++++++++++++++ 12 files changed, 188 insertions(+), 19 deletions(-) create mode 100644 bin/cli/commands/validate.js create mode 100644 examples/httpbin.yml create mode 100644 src/schema.js diff --git a/bin/cli/base.js b/bin/cli/base.js index 13a730d..9f437cd 100644 --- a/bin/cli/base.js +++ b/bin/cli/base.js @@ -6,13 +6,17 @@ const { Command, flags } = require('@oclif/command'); const Beau = require('../../src/beau'); class Base extends Command { - loadConfig(configFile) { + openConfigFile(configFile) { if (!fs.existsSync(configFile)) { this.error(`The config file, ${configFile} was not found.`); this.exit(1); } - const config = yaml.safeLoad(fs.readFileSync(configFile, 'utf-8')); + return yaml.safeLoad(fs.readFileSync(configFile, 'utf-8')); + } + + loadConfig(configFile) { + const config = this.openConfigFile(configFile); const env = dotenv.config().parsed || {}; return new Beau(config, env); diff --git a/bin/cli/commands/validate.js b/bin/cli/commands/validate.js new file mode 100644 index 0000000..04840c9 --- /dev/null +++ b/bin/cli/commands/validate.js @@ -0,0 +1,34 @@ +const clc = require('cli-color'); +const fs = require('fs'); +const yaml = require('js-yaml'); +const { flags } = require('@oclif/command'); + +const Base = require('../base'); +const { validate } = require('../../../src/schema.js'); + +class ValidateCommand extends Base { + async run() { + const { flags, args } = this.parse(ValidateCommand); + const configFile = args.alias || flags.config; + + const config = this.openConfigFile(configFile); + + let result = await validate(config); + if (result.valid) { + this.log(`${configFile} is valid.`); + } else { + this.error(result.message); + } + } +} + +ValidateCommand.description = `Validates the given configuration file against Beau's configuration schema.`; +ValidateCommand.flags = { ...Base.flags }; +ValidateCommand.args = [ + { + name: 'alias', + required: false, + description: `The configuration file to validate.` + } +]; +module.exports = ValidateCommand; diff --git a/examples/httpbin.yml b/examples/httpbin.yml new file mode 100644 index 0000000..136c934 --- /dev/null +++ b/examples/httpbin.yml @@ -0,0 +1,19 @@ +version: 1 +endpoint: https://httpbin.org/ + +cookiejar: true + +GET /anything: + alias: anything + form: + name: David + params: + hello: World + +GET /cookies/set: + alias: set-cookies + params: + hello: World + +GET /status/418: + alias: teapot diff --git a/examples/plugins.yml b/examples/plugins.yml index 61ca695..12718e2 100644 --- a/examples/plugins.yml +++ b/examples/plugins.yml @@ -1,7 +1,7 @@ endpoint: http://localhost:10080 plugins: - - beau-jwt: + - jwt: data: userId: 12 name: Sergio diff --git a/package-lock.json b/package-lock.json index 17ed48f..aa81998 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3934,6 +3934,21 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, + "isemail": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/isemail/-/isemail-3.1.2.tgz", + "integrity": "sha512-zfRhJn9rFSGhzU5tGZqepRSAj3+g6oTOHxMGGriWNJZzyLPUK8H7VHpqKntegnW8KLyGA9zwuNaCoopl40LTpg==", + "requires": { + "punycode": "2.x.x" + }, + "dependencies": { + "punycode": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.0.tgz", + "integrity": "sha1-X4Y+3Im5bbCQdLrXlHvwkFbKTn0=" + } + } + }, "isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4787,6 +4802,23 @@ "merge-stream": "^1.0.1" } }, + "joi": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/joi/-/joi-13.2.0.tgz", + "integrity": "sha512-VUzQwyCrmT2lIpxBCYq26dcK9veCQzDh84gQnCtaxCa8ePohX8JZVVsIb+E66kCUUcIvzeIpifa6eZuzqTZ3NA==", + "requires": { + "hoek": "5.x.x", + "isemail": "3.x.x", + "topo": "3.x.x" + }, + "dependencies": { + "hoek": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.3.tgz", + "integrity": "sha512-Bmr56pxML1c9kU+NS51SMFkiVQAb+9uFfXwyqR2tn4w2FPvmPt65eZ9aCcEfRXd9G74HkZnILC6p967pED4aiw==" + } + } + }, "js-tokens": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", @@ -7181,6 +7213,21 @@ } } }, + "topo": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.0.tgz", + "integrity": "sha512-Tlu1fGlR90iCdIPURqPiufqAlCZYzLjHYVVbcFWDMcX7+tK8hdZWAfsMrD/pBul9jqHHwFjNdf1WaxA9vTRRhw==", + "requires": { + "hoek": "5.x.x" + }, + "dependencies": { + "hoek": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.3.tgz", + "integrity": "sha512-Bmr56pxML1c9kU+NS51SMFkiVQAb+9uFfXwyqR2tn4w2FPvmPt65eZ9aCcEfRXd9G74HkZnILC6p967pED4aiw==" + } + } + }, "tough-cookie": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.3.tgz", diff --git a/package.json b/package.json index 6904fb7..32732eb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "clui": "^0.3.1", "deepmerge": "^2.1.0", "dotenv": "^5.0.1", + "joi": "^13.2.0", "globby": "^8.0.1", "is-plain-object": "^2.0.4", "js-yaml": "^3.11.0", diff --git a/src/__tests__/__snapshots__/beau.spec.js.snap b/src/__tests__/__snapshots__/beau.spec.js.snap index 931b384..4132a10 100644 --- a/src/__tests__/__snapshots__/beau.spec.js.snap +++ b/src/__tests__/__snapshots__/beau.spec.js.snap @@ -3,7 +3,6 @@ exports[`Beau's config Loader. should create a request list 1`] = ` Beau { "config": Config { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object { "headers": Object { @@ -45,7 +44,6 @@ Beau { "VERSION": 1, "configKeys": Array [ "VERSION", - "CACHE", "ENDPOINT", "PLUGINS", "DEFAULTS", @@ -54,7 +52,6 @@ Beau { "COOKIEJAR", ], "defaultConfigValues": Object { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object {}, "ENDPOINT": "", @@ -108,7 +105,6 @@ Beau { }, }, "config": Config { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object { "headers": Object { @@ -150,7 +146,6 @@ Beau { "VERSION": 1, "configKeys": Array [ "VERSION", - "CACHE", "ENDPOINT", "PLUGINS", "DEFAULTS", @@ -159,7 +154,6 @@ Beau { "COOKIEJAR", ], "defaultConfigValues": Object { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object {}, "ENDPOINT": "", diff --git a/src/__tests__/__snapshots__/config.spec.js.snap b/src/__tests__/__snapshots__/config.spec.js.snap index ea7c058..0cf6434 100644 --- a/src/__tests__/__snapshots__/config.spec.js.snap +++ b/src/__tests__/__snapshots__/config.spec.js.snap @@ -2,7 +2,6 @@ exports[`Config should load multiple hosts 1`] = ` Config { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object { "HEADERS": Object { @@ -113,7 +112,6 @@ Config { "VERSION": 1, "configKeys": Array [ "VERSION", - "CACHE", "ENDPOINT", "PLUGINS", "DEFAULTS", @@ -122,7 +120,6 @@ Config { "COOKIEJAR", ], "defaultConfigValues": Object { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object {}, "ENDPOINT": "", @@ -176,7 +173,6 @@ Config { exports[`Config should set up defaults for all requests 1`] = ` Config { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object { "HEADERS": Object { @@ -218,7 +214,6 @@ Config { "VERSION": 1, "configKeys": Array [ "VERSION", - "CACHE", "ENDPOINT", "PLUGINS", "DEFAULTS", @@ -227,7 +222,6 @@ Config { "COOKIEJAR", ], "defaultConfigValues": Object { - "CACHE": false, "COOKIEJAR": false, "DEFAULTS": Object {}, "ENDPOINT": "", diff --git a/src/__tests__/config.spec.js b/src/__tests__/config.spec.js index 921c923..6e4d52f 100644 --- a/src/__tests__/config.spec.js +++ b/src/__tests__/config.spec.js @@ -6,13 +6,11 @@ describe('Config', () => { const doc = yaml.safeLoad(` version: 1 endpoint: http://martianwabbit.com - cache: false shouldntBeAdded: true `); const config = new Config(doc); expect(config.ENDPOINT).toBe(doc.endpoint); - expect(config.CACHE).toBe(doc.cache); expect(config.VERSION).toBe(doc.version); expect(config.shouldntBeAdded).toBeUndefined(); }); diff --git a/src/config.js b/src/config.js index 0d9ee3f..6b4709e 100644 --- a/src/config.js +++ b/src/config.js @@ -6,7 +6,6 @@ class Config { constructor(doc, env = {}) { this.defaultConfigValues = { VERSION: 1, - CACHE: false, ENDPOINT: '', PLUGINS: [], DEFAULTS: {}, diff --git a/src/requestCache.js b/src/requestCache.js index 56cdf0c..9a5cef7 100644 --- a/src/requestCache.js +++ b/src/requestCache.js @@ -17,7 +17,7 @@ class RequestCache { let result = this.$cache; path.split('.').forEach(part => { if (result[part] === undefined) { - throw new Error(`${path} not found in cache: `, path); + throw new Error(`${path} not found in cache.`); } result = result[part]; diff --git a/src/schema.js b/src/schema.js new file mode 100644 index 0000000..1a6b933 --- /dev/null +++ b/src/schema.js @@ -0,0 +1,79 @@ +const Joi = require('joi'); +const { requestRegex } = require('./shared.js'); + +const pluginSchema = [ + Joi.string(), + Joi.object() + .keys(null) + .max(1) +]; + +const requestSchema = [ + Joi.object() + .keys({ + HEADERS: Joi.object().keys(null), + PAYLOAD: [Joi.object().keys(null), Joi.string()], + PARAMS: Joi.object().keys(null), + FORM: Joi.object().keys(null), + ALIAS: Joi.string().required(), + FORMDATA: Joi.object().keys(null) + }) + .without('FORM', ['PAYLOAD', 'FORMDATA']) + .without('PAYLOAD', ['FORM', 'FORMDATA']) + .without('FORMDATA', ['FORM', 'PAYLOAD']) + .rename(/headers/i, 'HEADERS', { override: true }) + .rename(/payload/i, 'PAYLOAD', { override: true }) + .rename(/params/i, 'PARAMS', { override: true }) + .rename(/form/i, 'FORM', { override: true }) + .rename(/alias/i, 'ALIAS', { override: true }), + + Joi.string() +]; + +const hostSchema = Joi.object() + .keys({ + HOST: Joi.string().required(), + ENDPOINT: Joi.string(), + DEFAULTS: Joi.object().keys(null) + }) + .pattern(requestRegex, requestSchema) + .rename(/host/i, 'HOST', { override: true }) + .rename(/defaults/i, 'DEFAULTS', { override: true }) + .rename(/endpoint/i, 'ENDPOINT', { override: true }); + +const schema = Joi.object() + .keys({ + VERSION: Joi.number().integer(), + ENDPOINT: Joi.string().uri(), + PLUGINS: Joi.array().items(pluginSchema), + DEFAULTS: Joi.object(), + ENVIRONMENT: Joi.object(), + HOSTS: Joi.array().items(hostSchema), + COOKIEJAR: Joi.boolean() + }) + .pattern(requestRegex, requestSchema) + .rename(/version/i, 'VERSION', { override: true }) + .rename(/endpoint/i, 'ENDPOINT', { override: true }) + .rename(/hosts/i, 'HOSTS', { override: true }) + .rename(/plugins/i, 'PLUGINS', { override: true }) + .rename(/defaults/i, 'DEFAULTS', { override: true }) + .rename(/environment/i, 'ENVIRONMENT', { override: true }) + .rename(/cookiejar/i, 'COOKIEJAR', { override: true }); + +const validate = async function(config) { + try { + let results = await Joi.validate(config, schema, { + allowUnknown: true + }); + return { valid: true }; + } catch ({ name, details }) { + return { + valid: false, + message: `${name}: \n ${details + .map(d => d.message + ' @ ' + d.path) + .join(' \n ')}` + }; + } +}; + +module.exports = { schema, validate };