Compare commits

..

No commits in common. "master" and "v0.9.5" have entirely different histories.

55 changed files with 5944 additions and 8002 deletions

View File

@ -1,37 +0,0 @@
name: Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2-beta
- name: Install dependencies
run: |
npm install
wget https://codeclimate.com/downloads/test-reporter/test-reporter-0.6.3-linux-amd64 -O cc-test-reporter
chmod +x ./cc-test-reporter
- name: Validate Schema
run: |
npx ajv-cli compile -s schema.json
npx ajv-cli validate -s schema.json -d examples/beau.yml
- name: Run CLI Tests
run: npm test -- ./bin
- name: Run Lib Tests
run: npm run test:coverage
- name: Report Results
if: success()
run: |
./cc-test-reporter format-coverage
./cc-test-reporter upload-coverage
env:
GIT_COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
GIT_BRANCH: ${{ github.head_ref }}
CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE_REPO_TOKEN }}

View File

@ -5,6 +5,7 @@ useTabs: false
trailingComma: none trailingComma: none
bracketSpacing: true bracketSpacing: true
jsxBracketSameLine: true jsxBracketSameLine: true
semi: false parser: babylon
semi: true
requirePragma: false requirePragma: false
proseWrap: always proseWrap: always

42
LICENSE
View File

@ -1,41 +1,7 @@
“Commons Clause” License Condition v1.0 Copyright 2018 David Sergio Díaz
The Software is provided to you by the Licensor under the License, as defined Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
License will not include, and the License does not grant to you, the right to
Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or all of the rights THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
granted to you under the License to provide to third parties, for a fee or other
consideration (including without limitation fees for hosting or consulting/
support services related to the Software), a product or service whose value
derives, entirely or substantially, from the functionality of the Software. Any
license notice or attribution required by the License must also include this
Commons Clause License Condition notice.
Software: Beau
License: MIT
Licensor: David Díaz
---
Copyright 2020 David Díaz
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="media/beau.png" height="144" alt="Beau's Logo is a Seahorse" /> <img src="http://files.martianwabbit.com/beau.png?1" height="144"/>
</div> </div>
<h1 align="center">Beau</h1> <h1 align="center">Beau</h1>
@ -7,7 +7,7 @@
<p align="center"> <p align="center">
<a href="https://codeclimate.com/github/Seich/Beau/maintainability"><img src="https://api.codeclimate.com/v1/badges/bc2de4d71893d6a2d18b/maintainability" /></a> <a href="https://codeclimate.com/github/Seich/Beau/maintainability"><img src="https://api.codeclimate.com/v1/badges/bc2de4d71893d6a2d18b/maintainability" /></a>
<a href="https://codeclimate.com/github/Seich/Beau/test_coverage"><img src="https://api.codeclimate.com/v1/badges/bc2de4d71893d6a2d18b/test_coverage" /></a> <a href="https://codeclimate.com/github/Seich/Beau/test_coverage"><img src="https://api.codeclimate.com/v1/badges/bc2de4d71893d6a2d18b/test_coverage" /></a>
<img src="https://github.com/Seich/Beau/workflows/Tests/badge.svg"/> <a href="https://circleci.com/gh/Seich/Beau/tree/master"><img src="https://circleci.com/gh/Seich/Beau/tree/master.svg?style=svg" alt="CircleCI"></a>
</p> </p>
## What is Beau? ## What is Beau?
@ -15,9 +15,7 @@
Beau is a modern http client. It uses a YAML file as configuration allowing you Beau is a modern http client. It uses a YAML file as configuration allowing you
to test APIs without having to write lengthy commands. to test APIs without having to write lengthy commands.
<div align="center"> ![A Gif showing how beau works](http://files.martianwabbit.com/beau2.gif)
<img src="media/usage.gif" alt="A gif showing how beau works." />
</div>
## Installation ## Installation
@ -41,7 +39,8 @@ endpoint: https://httpbin.org/
POST /anything: POST /anything:
alias: anything alias: anything
payload: payload:
hello: world username: sergio
password: password1
``` ```
``` ```
@ -53,7 +52,8 @@ Status Endpoint
{ {
... ...
json: { json: {
hello: "world" password: "password1",
username: "sergio"
}, },
method: "POST", method: "POST",
url: "https://httpbin.org/anything" url: "https://httpbin.org/anything"

View File

@ -1,30 +0,0 @@
const Beau = require('../../../src/beau')
const original = jest.requireActual('../base')
const config = {
environment: {
params: {
name: 'David'
}
},
endpoint: 'https://example.org',
version: 1,
'GET /anything': {
alias: 'alias',
payload: {
name: '$env.params.name'
}
},
'GET /status/418': {
alias: 'teapot'
}
}
class Base extends original {
loadConfig(configFile, params = []) {
return new Beau(config, {})
}
}
module.exports = Base

View File

@ -1,6 +1,6 @@
const fs = jest.genMockFromModule('fs') const fs = jest.genMockFromModule('fs');
fs.existsSync = (filename) => filename === 'beau.yml' fs.existsSync = filename => filename === 'beau.yml';
fs.readFileSync = () => ` fs.readFileSync = () => `
version: 1 version: 1
endpoint: https://example.org/ endpoint: https://example.org/
@ -9,6 +9,6 @@ GET /anything:
alias: anything alias: anything
payload: payload:
name: $env.params.name name: $env.params.name
` `;
module.exports = fs module.exports = fs;

View File

@ -0,0 +1,41 @@
const Beau = require('../../../src/beau');
const original = require.requireActual('../utils');
const utils = {};
const config = {
environment: {
params: {
name: 'David'
}
},
endpoint: 'https://example.org',
version: 1,
'GET /anything': {
alias: 'alias',
payload: {
name: '$env.params.name'
}
},
'GET /status/418': {
alias: 'teapot'
}
};
utils.loadConfig = function() {
return new Beau(config, {});
};
utils.openConfigFile = function(filename) {
if (filename === 'beau.yml') {
return config;
}
if (filename === 'invalid-conf.yml') {
return { plugins: [{ hello: 1, world: 2 }] };
}
};
utils.baseFlags = original.baseFlags;
module.exports = utils;

View File

@ -9,7 +9,7 @@ Array [
", ",
" "
", ",
"{\\"hello\\": \\"world\\"} "\\"{\\"hello\\": \\"world\\"}\\"
", ",
] ]
`; `;
@ -53,18 +53,18 @@ Array [
" "
", ",
"{ "{
\\"request\\": { request: {
\\"body\\": { body: {
\\"name\\": \\"David\\" name: \\"David\\"
}, },
\\"endpoint\\": \\"https://example.org/anything\\" endpoint: \\"https://example.org/anything\\"
}, },
\\"response\\": { response: {
\\"status\\": 200, status: 200,
\\"headers\\": [], headers: [],
\\"body\\": \\"{\\\\\\"hello\\\\\\": \\\\\\"world\\\\\\"}\\" body: \\"{\\"hello\\": \\"world\\"}\\"
}, },
\\"body\\": \\"{\\\\\\"hello\\\\\\": \\\\\\"world\\\\\\"}\\" body: \\"{\\"hello\\": \\"world\\"}\\"
} }
", ",
] ]

View File

@ -0,0 +1,74 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`utils loadConfig should load load the config onto Beau 1`] = `
Config {
"COOKIEJAR": false,
"DEFAULTS": Object {},
"ENDPOINT": "https://example.org/",
"ENVIRONMENT": Object {
"_": Object {},
},
"HOSTS": Array [],
"PLUGINS": Plugins {
"autoload": Array [
"std",
],
"context": Object {
"createReadStream": [Function],
},
"registry": Object {
"dynamicValues": Array [
Object {
"fn": [Function],
"name": "createReadStream",
},
],
"postRequestModifiers": Array [],
"preRequestModifiers": Array [],
},
},
"REQUESTS": Array [
Object {
"ALIAS": "anything",
"COOKIEJAR": false,
"ENDPOINT": "https://example.org/",
"PAYLOAD": Object {
"name": "$env.params.name",
},
"REQUEST": "GET /anything",
},
],
"VERSION": 1,
"configKeys": Array [
"VERSION",
"ENDPOINT",
"PLUGINS",
"DEFAULTS",
"ENVIRONMENT",
"HOSTS",
"COOKIEJAR",
],
}
`;
exports[`utils loadConfig should load params onto the environment 1`] = `
Object {
"_": Object {
"BYE": "MARS",
"HELLO": "WORLD",
},
}
`;
exports[`utils openConfigFile should read and parse the given configuration file. 1`] = `
Object {
"GET /anything": Object {
"alias": "anything",
"payload": Object {
"name": "$env.params.name",
},
},
"endpoint": "https://example.org/",
"version": 1,
}
`;

View File

@ -0,0 +1,8 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Validate Command should validate the configuration file 1`] = `
Array [
"beau.yml is valid.
",
]
`;

View File

@ -1,23 +1,23 @@
const ListCommand = require('../commands/list') const ListCommand = require('../commands/list');
jest.mock('../../../src/shared') jest.mock('../../../src/shared');
jest.mock('../base') jest.mock('../utils');
describe('List Command', () => { describe('List Command', () => {
let result let result;
beforeEach(() => { beforeEach(() => {
result = [] result = [];
jest.spyOn(process.stdout, 'write').mockImplementation((val) => jest.spyOn(process.stdout, 'write').mockImplementation(val =>
result.push(require('strip-ansi')(val.toString('utf8'))) result.push(require('strip-ansi')(val.toString('utf8')))
) );
}) });
afterEach(() => jest.restoreAllMocks()) afterEach(() => jest.restoreAllMocks());
test.each([[], ['--no-format']])('with flags:', async (...args) => { test.each([[], ['--no-format']])('with flags:', async (...args) => {
await ListCommand.run(args) await ListCommand.run(args);
expect(result).toMatchSnapshot() expect(result).toMatchSnapshot();
}) });
}) });

View File

@ -1,22 +1,22 @@
const RequestCommand = require('../commands/request') const RequestCommand = require('../commands/request');
const requestPromiseNativeMock = require('request-promise-native') const requestPromiseNativeMock = require('request-promise-native');
jest.mock('../../../src/shared') jest.mock('../../../src/shared');
jest.mock('../base') jest.mock('../utils');
describe('Request Command', () => { describe('Request Command', () => {
let result let result;
beforeEach(() => { beforeEach(() => {
requestPromiseNativeMock.fail = false requestPromiseNativeMock.fail = false;
result = [] result = [];
jest.spyOn(process.stdout, 'write').mockImplementation((val) => jest.spyOn(process.stdout, 'write').mockImplementation(val =>
result.push(require('strip-ansi')(val.toString('utf8'))) result.push(require('strip-ansi')(val.toString('utf8')))
) );
}) });
afterEach(() => jest.restoreAllMocks()) afterEach(() => jest.restoreAllMocks());
test.each([ test.each([
['alias'], ['alias'],
@ -26,12 +26,12 @@ describe('Request Command', () => {
['alias', '--no-format'], ['alias', '--no-format'],
['alias', '--quiet'] ['alias', '--quiet']
])('with flags: %s %s %s', async (...args) => { ])('with flags: %s %s %s', async (...args) => {
await RequestCommand.run(args) await RequestCommand.run(args);
expect(result).toMatchSnapshot() expect(result).toMatchSnapshot();
}) });
it('should throw an error when the request fails', async () => { it('should throw an error when the request fails', async () => {
requestPromiseNativeMock.fail = true requestPromiseNativeMock.fail = true;
await expect(RequestCommand.run(['anything'])).rejects.toThrow(Error) await expect(RequestCommand.run(['anything'])).rejects.toThrow(Error);
}) });
}) });

View File

@ -0,0 +1,32 @@
const utils = require('../utils.js');
jest.mock('../../../src/shared');
jest.mock('fs');
describe('utils', () => {
describe('openConfigFile', () => {
it('should read and parse the given configuration file.', () => {
expect(utils.openConfigFile('beau.yml')).toMatchSnapshot();
});
it('should throw if given not given a file', () => {
expect(() => utils.openConfigFile('not-a-file.yml')).toThrow();
});
});
describe('loadConfig', () => {
it('should load load the config onto Beau', () => {
let beau = utils.loadConfig('beau.yml');
expect(beau.config).toMatchSnapshot();
});
it('should load params onto the environment', () => {
let beau = utils.loadConfig('beau.yml', [
'HELLO=WORLD',
'BYE=MARS'
]);
expect(beau.config.ENVIRONMENT).toMatchSnapshot();
});
});
});

View File

@ -0,0 +1,27 @@
const ValidateCommand = require('../commands/validate');
jest.mock('../utils');
describe('Validate Command', () => {
let result;
beforeEach(() => {
result = [];
jest.spyOn(process.stdout, 'write').mockImplementation(val =>
result.push(require('strip-ansi')(val.toString('utf8')))
);
});
afterEach(() => jest.restoreAllMocks());
it('should validate the configuration file', async () => {
await ValidateCommand.run([]);
expect(result).toMatchSnapshot();
});
it('should show schema errors', async () => {
await expect(
ValidateCommand.run(['invalid-conf.yml'])
).rejects.toThrow();
});
});

View File

@ -1,77 +0,0 @@
const { Command, flags } = require('@oclif/command')
const yaml = require('js-yaml')
const fs = require('fs')
const path = require('path')
const dotenv = require('dotenv')
const Beau = require('../../src/beau')
const Ajv = require('ajv').default
const betterAjvErrors = require('better-ajv-errors')
const schema = require('../../schema.json')
const ajv = new Ajv()
const validate = ajv.compile(schema)
class Base extends Command {
openConfigFile(configFile) {
if (!fs.existsSync(configFile)) {
throw new Error(`The config file, ${configFile} was not found.`)
}
let config
yaml.loadAll(fs.readFileSync(configFile, 'utf-8'), (doc) => {
const valid = validate(doc)
if (!valid) {
this.log(`The configuration file is not valid.`)
this.error(
betterAjvErrors(schema, doc, validate.errors, { indent: 2 })
)
}
if (typeof config === 'undefined') {
config = doc
} else {
if (typeof config.hosts === 'undefined') {
config.hosts = []
}
config.hosts.push(doc)
}
})
return config
}
loadConfig(configFile, params = []) {
const config = this.openConfigFile(configFile)
const env = dotenv.config().parsed || {}
params = dotenv.parse(params.join('\n'))
const envParams = { _: Object.assign(env, params) }
const configFileDir = path.dirname(
path.resolve(process.cwd(), configFile)
)
process.chdir(configFileDir)
return new Beau(config, envParams)
}
}
Base.flags = {
config: flags.string({
char: 'c',
description: 'The configuration file to be used.',
default: 'beau.yml'
}),
verbose: flags.boolean({
char: 'V',
description: `Show all additional information available for a command.`
}),
'no-format': flags.boolean({
description: `Disables color formatting for usage on external tools.`
})
}
module.exports = Base

View File

@ -1,23 +1,26 @@
const clc = require('cli-color') const clc = require('cli-color');
const { Line } = require('clui') const { Line } = require('clui');
const { expandPath } = require('../../../src/shared') const { flags, Command } = require('@oclif/command');
const Base = require('../base') const { baseFlags, loadConfig } = require('../utils');
class ListCommand extends Base { class ListCommand extends Command {
async run() { async run() {
const { flags } = this.parse(ListCommand) const { flags } = this.parse(ListCommand);
const Beau = this.loadConfig(flags.config) const Beau = loadConfig(flags.config);
if (flags['no-format']) { if (flags['no-format']) {
return Beau.requests.list.forEach( return Beau.requests.list.forEach(
({ VERB, ALIAS, ENDPOINT, PATH }) => ({ VERB, ALIAS, ENDPOINT, PATH }) =>
this.log( this.log(
`${VERB}\t${ALIAS}\t${ENDPOINT.replace( VERB +
/\/$/, `\t` +
'' ALIAS +
)}/${PATH.replace(/^\//, '')}` `\t` +
ENDPOINT.replace(/\/$/, '') +
`/` +
PATH.replace(/^\//, '')
) )
) );
} }
new Line() new Line()
@ -25,22 +28,24 @@ class ListCommand extends Base {
.column('HTTP Verb', 20, [clc.cyan]) .column('HTTP Verb', 20, [clc.cyan])
.column('Alias', 30, [clc.cyan]) .column('Alias', 30, [clc.cyan])
.column('Endpoint', 20, [clc.cyan]) .column('Endpoint', 20, [clc.cyan])
.output() .output();
Beau.requests.list.forEach(({ VERB, ALIAS, ENDPOINT, PATH }) => Beau.requests.list.forEach(({ VERB, ALIAS, ENDPOINT, PATH }) =>
new Line() new Line()
.padding(2) .padding(2)
.column(VERB, 20, [clc.yellow]) .column(VERB, 20, [clc.yellow])
.column(ALIAS, 30, [clc.yellow]) .column(ALIAS, 30, [clc.yellow])
.column(expandPath(ENDPOINT, PATH)) .column(
ENDPOINT.replace(/\/$/, '') + '/' + PATH.replace(/^\//, '')
)
.output() .output()
) );
new Line().output() new Line().output();
} }
} }
ListCommand.description = `Lists all available requests in the config file.` ListCommand.description = `Lists all available requests in the config file.`;
ListCommand.flags = { ...Base.flags } ListCommand.flags = { ...baseFlags };
module.exports = ListCommand module.exports = ListCommand;

View File

@ -1,41 +1,34 @@
const Base = require('../base') const clc = require('cli-color');
const cj = require('color-json') const jsome = require('jsome');
const clc = require('cli-color') const { Line, Spinner } = require('clui');
const prompts = require('prompts') const { flags, Command } = require('@oclif/command');
const { Line, Spinner } = require('clui') const { baseFlags, loadConfig } = require('../utils');
const { flags } = require('@oclif/command')
const { expandPath } = require('../../../src/shared')
class RequestCommand extends Base { class RequestCommand extends Command {
prettyOutput(res, verbose = false) { prettyOutput(res, verbose = false) {
let { status, body } = res.response let { status, body } = res.response;
this.spinner.stop() this.spinner.stop();
status = status.toString().startsWith(2) status = status.toString().startsWith(2)
? clc.green(status) ? clc.green(status)
: clc.red(status) : clc.red(status);
new Line() new Line()
.padding(2) .padding(2)
.column('Status', 20, [clc.cyan]) .column('Status', 20, [clc.cyan])
.column('Endpoint', 20, [clc.cyan]) .column('Endpoint', 20, [clc.cyan])
.output() .output();
new Line() new Line()
.padding(2) .padding(2)
.column(status, 20) .column(status, 20)
.column(res.request.endpoint) .column(res.request.endpoint)
.output() .output();
new Line().output() new Line().output();
const result = (verbose ? res : body) || null this.log(jsome.getColoredString((verbose ? res : body) || null));
if (typeof result === 'object') {
this.log(cj(result))
} else if (typeof result === 'string') {
this.log(result)
}
} }
async run() { async run() {
@ -46,84 +39,59 @@ class RequestCommand extends Base {
'no-format': noFormat = false, 'no-format': noFormat = false,
verbose = false, verbose = false,
'as-json': asJson = false, 'as-json': asJson = false,
quiet = false, quiet = false
interactive = false
}, },
args args
} = this.parse(RequestCommand) } = this.parse(RequestCommand);
const Beau = this.loadConfig(config, params) const Beau = loadConfig(config, params);
const spinnerSprite = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'] const spinnerSprite = ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'];
this.spinner = new Spinner('', spinnerSprite) this.spinner = new Spinner('', spinnerSprite);
let spinnerEnabled = !noFormat && !asJson && !quiet let spinnerEnabled = !noFormat && !asJson && !quiet;
if (typeof args.alias == 'undefined' && !interactive) {
this.error(
'Missing 1 required argument: The alias of the request to execute.'
)
}
if (interactive) {
const requests = Beau.requests.list.map(
({ VERB, ALIAS, ENDPOINT, PATH }) => ({
title: `${VERB} ${PATH} - ${ALIAS}`,
value: ALIAS,
description: expandPath(ENDPOINT, PATH)
})
)
const { name } = await prompts({
name: 'name',
message: 'Pick a Request to execute',
type: 'select',
choices: requests
})
args.alias = name
}
if (spinnerEnabled) { if (spinnerEnabled) {
this.spinner.start() this.spinner.start();
} }
let res let res;
try { try {
res = await Beau.requests.execByAlias(args.alias) res = await Beau.requests.execByAlias(args.alias);
} catch (err) { } catch (err) {
this.spinner.stop() this.spinner.stop();
if (!quiet) { if (!quiet) {
this.error(err.message) this.error(err.message);
} }
this.exit(1) this.exit(1);
} }
if (quiet) { if (quiet) {
return return;
} }
if (asJson) { if (asJson) {
return this.log(JSON.stringify(verbose ? res : res.response)) return this.log(JSON.stringify(verbose ? res : res.response));
} }
if (noFormat) { if (noFormat) {
this.log(res.response.status) this.log(res.response.status);
this.log(res.request.endpoint) this.log(res.request.endpoint);
this.log(JSON.stringify(res.response.headers)) this.log(JSON.stringify(res.response.headers));
this.log(JSON.stringify(res.response.body)) this.log(JSON.stringify(res.response.body));
return return;
} }
this.prettyOutput(res, verbose) this.prettyOutput(res, verbose);
} }
} }
RequestCommand.description = `Executes a request by name.` RequestCommand.description = `Executes a request by name.`;
RequestCommand.flags = { RequestCommand.flags = {
...Base.flags, ...baseFlags,
param: flags.string({ param: flags.string({
char: 'P', char: 'P',
multiple: true, multiple: true,
@ -138,21 +106,15 @@ RequestCommand.flags = {
'as-json': flags.boolean({ 'as-json': flags.boolean({
char: 'j', char: 'j',
description: `Outputs the response as json.` description: `Outputs the response as json.`
}),
interactive: flags.boolean({
char: 'i',
description: 'Choose request interactively.',
default: false
}) })
} };
RequestCommand.args = [ RequestCommand.args = [
{ {
name: 'alias', name: 'alias',
required: false, required: true,
description: `The alias of the request to execute.` description: `The alias of the request to execute.`
} }
] ];
module.exports = RequestCommand module.exports = RequestCommand;

View File

@ -0,0 +1,30 @@
const { flags, Command } = require('@oclif/command');
const { baseFlags, openConfigFile } = require('../utils');
const { validate } = require('../../../src/schema.js');
class ValidateCommand extends Command {
async run() {
const { flags, args } = this.parse(ValidateCommand);
const configFile = args.alias || flags.config;
const config = 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 = { ...baseFlags };
ValidateCommand.args = [
{
name: 'alias',
required: false,
description: `The configuration file to validate.`
}
];
module.exports = ValidateCommand;

62
bin/cli/utils.js Normal file
View File

@ -0,0 +1,62 @@
const yaml = require('js-yaml');
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const Beau = require('../../src/beau');
const { flags } = require('@oclif/command');
const openConfigFile = configFile => {
if (!fs.existsSync(configFile)) {
throw new Error(`The config file, ${configFile} was not found.`);
}
let config;
yaml.safeLoadAll(fs.readFileSync(configFile, 'utf-8'), doc => {
if (typeof config === 'undefined') {
config = doc;
} else {
if (typeof config.hosts === 'undefined') {
config.hosts = [];
}
config.hosts.push(doc);
}
});
return config;
};
const loadConfig = (configFile, params = []) => {
const config = openConfigFile(configFile);
const env = dotenv.config().parsed || {};
params = dotenv.parse(params.join('\n'));
const envParams = { _: Object.assign(env, params) };
const configFileDir = path.dirname(path.resolve(process.cwd(), configFile));
process.chdir(configFileDir);
return new Beau(config, envParams);
};
const baseFlags = {
config: flags.string({
char: 'c',
description: 'The configuration file to be used.',
default: 'beau.yml'
}),
verbose: flags.boolean({
char: 'V',
description: `Show all additional information available for a command.`
}),
'no-format': flags.boolean({
description: `Disables color formatting for usage on external tools.`
})
};
module.exports = {
openConfigFile,
loadConfig,
baseFlags
};

12
circle.yml Normal file
View File

@ -0,0 +1,12 @@
machine:
node:
version: 8.9.3
dependencies:
post:
- npm install -g codeclimate-test-reporter
test:
pre:
- npm run test:coverage
- codeclimate-test-reporter < ./coverage/lcov.info

View File

@ -1,37 +0,0 @@
endpoint: https://pokeapi.co/api/v2/
# Try replacing this pokemon using params:
# beau request get-pokemon -P "pokemon=dragapult"
environment:
_:
pokemon: ditto
cookiejar: true
GET /pokemon/$env._.pokemon: get-pokemon
GET $get-pokemon.body.location_area_encounters: get-encounters
POST https://httpbin.org/anything:
- alias: post-first-area
payload:
area: $get-encounters.body.0.location_area.name
- alias: post-pokemon-type
payload:
type: $get-pokemon.body.types.0.type.name
- alias: form-submission
form:
name: Dragapult
- alias: file-upload
formdata:
name: Beau
logo: $[createReadStream('../media/beau.png')]
GET https://httpbin.org/status/418: teapot
GET https://httpbin.org/cookies/set:
alias: set-cookies
params:
hello: World

15
examples/github.yml Normal file
View File

@ -0,0 +1,15 @@
VERSION: 1
ENDPOINT: https://api.github.com
auth: &auth
HEADERS:
Authorization: token asfdasf123423sd1fgnh7d83n478
User-Agent: Beau
GET /user:
ALIAS: $user
<<: *auth
GET /user/repos:
ALIAS: $repos
<<: *auth

22
examples/hosts.yml Normal file
View File

@ -0,0 +1,22 @@
version: 1
endpoint: http://httpbin.org
environment:
the:
post: 2
defaults:
headers:
hello: 'Hello2'
POST /anything:
alias: anything
payload:
title: $jpa:get-post.body.title
---
host: jpa
endpoint: http://jsonplaceholder.typicode.com
GET /posts/$env.the.post: get-post
GET /users/$jpa:get-post.body.userId: hello

26
examples/httpbin.yml Normal file
View File

@ -0,0 +1,26 @@
version: 1
endpoint: https://httpbin.org/
cookiejar: true
environment:
params:
name: David
GET /anything:
alias: anything
payload:
name: $env.params.name
GET /cookies/set:
alias: set-cookies
params:
hello: World
GET /status/418:
alias: teapot
POST /post:
alias: post
formdata:
id: $[uuid()]
file: $[createReadStream('./github.yml')]

View File

@ -0,0 +1,29 @@
version: 1
endpoint: 'http://jsonplaceholder.typicode.com'
environment:
the:
post: 1
defaults:
headers:
hello: $posts.body.0.userId
GET /posts/$env.the.post: get-post
GET /posts/:
alias: posts
headers:
hello: false
POST /posts/:
alias: new-post
documentation:
title: New Post
GET /users/$posts.body.0.userId:
alias: post-user
documentation:
description: Fetches the user for a give post.
params:
hello: 'world'

11
examples/plugins.yml Normal file
View File

@ -0,0 +1,11 @@
endpoint: http://localhost:10080
plugins:
- jwt:
data:
userId: 12
name: Sergio
secret: 'asdfasdf+asdfasdf/asdfasdfasdfasdf=='
GET /test:
alias: test

37
examples/slack.yml Normal file
View File

@ -0,0 +1,37 @@
VERSION: '1'
ENDPOINT: https://slack.com/api
auth: &auth
token: xoxp-139455775026-139455775090-147751461120-f224ed6ffee029869a0f138d0859e7d6
GET /users.getPresence:
ALIAS: presence
PARAMS:
<<: *auth
GET /channels.list:
ALIAS: channel-list
PARAMS:
<<: *auth
exclude_archived: true
GET /channels.info:
ALIAS: channel-info
PARAMS:
<<: *auth
channel: $channel-list.response.body.channels.0.id
POST /chat.postMessage:
ALIAS: new-message
PARAMS:
<<: *auth
channel: $channel-info.response.body.channel.id
text: 'Hey Seich!'
parse: full
link_names: true
username: Beau
GET /users.list:
ALIAS: user-list
PARAMS:
<<: *auth

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 616 KiB

11607
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,12 @@
{ {
"name": "beau", "name": "beau",
"version": "0.11.3", "version": "0.9.5",
"description": "Testing APIs made easy.", "description": "Testing APIs made easy.",
"main": "./src/beau.js", "main": "./src/beau.js",
"author": "David Díaz <seich@martianwabbit.com>", "author": "Sergio Diaz <seich@martianwabbit.com>",
"license": "MIT + Commons Clause", "license": "MIT",
"scripts": { "scripts": {
"test": "jest -i", "test": "jest",
"test:coverage": "jest --coverage ./src", "test:coverage": "jest --coverage ./src",
"release": "np" "release": "np"
}, },
@ -15,32 +15,30 @@
"/bin" "/bin"
], ],
"dependencies": { "dependencies": {
"@oclif/command": "1.8.0", "@oclif/command": "^1.4.34",
"@oclif/config": "1.17.0", "@oclif/config": "^1.6.33",
"@oclif/plugin-help": "3.2.1", "@oclif/plugin-help": "^2.0.5",
"@oclif/plugin-warn-if-update-available": "1.7.0", "@oclif/plugin-warn-if-update-available": "^1.3.9",
"ajv": "7.0.3", "beau-std": "^0.9.4",
"beau-std": "0.9.4", "cli-color": "^1.1.0",
"better-ajv-errors": "0.7.0", "clui": "^0.3.1",
"cli-color": "2.0.0", "deepmerge": "^2.1.1",
"clui": "0.3.6", "dotenv": "^6.0.0",
"color-json": "2.0.1", "globby": "^8.0.1",
"deepmerge": "4.2.2", "is-plain-object": "^2.0.4",
"dotenv": "8.2.0", "joi": "^13.4.0",
"globby": "11.0.2", "js-yaml": "^3.12.0",
"is-plain-object": "5.0.0", "jsome": "^2.5.0",
"js-yaml": "4.0.0", "request": "^2.87.0",
"prompts": "2.4.0", "request-promise-native": "^1.0.5",
"request": "2.88.2", "requireg": "^0.1.8"
"request-promise-native": "1.0.9",
"requireg": "0.2.2"
}, },
"repository": "git@github.com:Seich/Beau.git", "repository": "git@github.com:Seich/Beau.git",
"devDependencies": { "devDependencies": {
"jest": "26.6.3", "jest": "^23.4.1",
"jest-watch-typeahead": "0.6.1", "jest-watch-typeahead": "^0.2.0",
"strip-ansi": "6.0.0", "np": "^3.0.4",
"np": "7.2.0" "strip-ansi": "^4.0.0"
}, },
"oclif": { "oclif": {
"commands": "./bin/cli/commands", "commands": "./bin/cli/commands",

View File

@ -1,122 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://beaujs.com/schema.json",
"title": "Beaujs Requests Schema",
"type": "object",
"definitions": {
"request": {
"oneOf": [
{ "type": "string" },
{
"type": "object",
"$ref": "#/definitions/requestObject",
"required": ["alias"]
}
]
},
"requestObject": {
"type": "object",
"properties": {
"alias": {
"type": "string",
"description": "The name of this request."
},
"headers": {
"type": "object",
"additionalProperties": true,
"description": "Headers that are part of this request."
},
"params": {
"type": "object",
"additionalProperties": true,
"description": "Query String parameters to add to this request."
},
"payload": {
"description": "This request's body. It is converted to json automatically if given an object. It's sent as a string otherwise.",
"oneOf": [
{ "type": "string" },
{
"type": "object",
"additionalProperties": true
}
]
},
"form": {
"type": "object",
"additionalProperties": true,
"description": "This request's body. Sets the content-type to application/x-www-form-urlencoded."
},
"formdata": {
"type": "object",
"additionalProperties": true,
"description": "This request's body. Sets the content-type to multipart/form-data."
}
},
"allOf": [
{ "not": { "required": ["payload", "form"] } },
{ "not": { "required": ["payload", "formdata"] } },
{ "not": { "required": ["formdata", "form"] } }
]
}
},
"properties": {
"version": {
"type": "number",
"description": "The beau version this document was created for.",
"enum": [1]
},
"endpoint": {
"type": "string",
"description": "The root endpoint for this host."
},
"cookiejar": {
"type": "boolean",
"description": "Enable cookie support for requests?"
},
"host": {
"type": "string",
"description": "The name of the current host. It allows referencing requests between different hosts."
},
"defaults": {
"description": "Default values to be added to all requests.",
"$ref": "#/definitions/requestObject"
},
"plugins": {
"description": "Plugins to be enabled for this document.",
"type": "array",
"items": {
"anyOf": [
{ "type": "string" },
{ "type": "object", "additionalProperties": true }
]
}
},
"environment": {
"type": "object",
"description": "Global document variables for easy access.",
"additionalProperties": true,
"properties": {
"_": {
"type": "object",
"description": "Environment variables brought in by cli params or dotenv.",
"additionalProperties": true
}
}
}
},
"patternProperties": {
"(GET|HEAD|POST|PUT|DELETE|CONNECT|OPTIONS|TRACE|PATCH)\\s.*": {
"oneOf": [
{
"$ref": "#/definitions/request"
},
{
"type": "array",
"items": {
"$ref": "#/definitions/request"
}
}
]
}
}
}

View File

@ -1,11 +1,11 @@
class DynamicValues { class DynamicValues {
constructor(registry, settings = {}) { constructor(registry, settings = {}) {
registry.defineDynamicValue('add', this.add) registry.defineDynamicValue('add', this.add);
} }
add(x, y) { add(x, y) {
return x + y return x + y;
} }
} }
module.exports = DynamicValues module.exports = DynamicValues;

View File

@ -1,20 +1,20 @@
class Modifiers { class Modifiers {
constructor(registry, settings = {}) { constructor(registry, settings = {}) {
registry.addPreRequestModifier(this.preRequest) registry.addPreRequestModifier(this.preRequest);
registry.addPostRequestModifier(this.postRequest) registry.addPostRequestModifier(this.postRequest);
} }
preRequest(request, orig) { preRequest(request, orig) {
request.headers = request.headers || {} request.headers = request.headers || {};
request.headers.preRequestModifier = true request.headers.preRequestModifier = true;
return request return request;
} }
postRequest(response, orig) { postRequest(response, orig) {
response.body = 'Hello World' response.body = 'Hello World';
response.response.body = 'Hello World' response.response.body = 'Hello World';
return response return response;
} }
} }
module.exports = Modifiers module.exports = Modifiers;

View File

@ -1,7 +1,7 @@
class BeauStd { class BeauStd {
constructor(registry, settings) { constructor(registry, settings) {
registry.defineDynamicValue('createReadStream', () => {}) registry.defineDynamicValue('createReadStream', () => {});
} }
} }
module.exports = BeauStd module.exports = BeauStd;

View File

@ -1,6 +1,6 @@
function Request(request) { function Request(request) {
if (Request.fail) { if (Request.fail) {
throw new Error() throw new Error();
} }
return { return {
@ -14,9 +14,9 @@ function Request(request) {
statusCode: 200, statusCode: 200,
headers: [], headers: [],
body: '{"hello": "world"}' body: '{"hello": "world"}'
} };
} }
Request.fail = false Request.fail = false;
module.exports = Request module.exports = Request;

View File

@ -1,15 +1,15 @@
function requireg(name) { function requireg(name) {
return require(name) return require(name);
} }
requireg.resolving = true requireg.resolving = true;
requireg.resolve = function (name) { requireg.resolve = function(name) {
if (requireg.resolving) { if (requireg.resolving) {
return '' return '';
} else { } else {
return undefined return undefined;
} }
} };
module.exports = requireg module.exports = requireg;

View File

@ -1,4 +1,4 @@
module.exports = { module.exports = {
...jest.requireActual('../shared'), ...require.requireActual('../shared'),
moduleVersion: jest.fn().mockReturnValue(1) moduleVersion: jest.fn().mockReturnValue(1)
} };

View File

@ -36,6 +36,18 @@ Beau {
], ],
}, },
"requests": RequestList { "requests": RequestList {
"PLUGINS": Plugins {
"autoload": Array [
"std",
],
"context": Object {},
"registry": Object {
"dynamicValues": Array [],
"postRequestModifiers": Array [],
"preRequestModifiers": Array [],
},
},
"REQUESTS": Array [],
"cache": RequestCache { "cache": RequestCache {
"$cache": Object { "$cache": Object {
"env": Object {}, "env": Object {},
@ -48,6 +60,34 @@ Beau {
exports[`Beau's config Loader. should load the request list using the configuration 1`] = ` exports[`Beau's config Loader. should load the request list using the configuration 1`] = `
RequestList { RequestList {
"PLUGINS": Plugins {
"autoload": Array [
"std",
],
"context": Object {},
"registry": Object {
"dynamicValues": Array [],
"postRequestModifiers": Array [],
"preRequestModifiers": Array [],
},
},
"REQUESTS": Array [
Object {
"ALIAS": "get-post",
"COOKIEJAR": false,
"ENDPOINT": "http://example.com",
"REQUEST": "GET /posts/1",
},
Object {
"ALIAS": "user",
"COOKIEJAR": false,
"ENDPOINT": "http://example.com",
"HEADERS": Object {
"hello": "world",
},
"REQUEST": "GET /user",
},
],
"cache": RequestCache { "cache": RequestCache {
"$cache": Object { "$cache": Object {
"env": Object {}, "env": Object {},

View File

@ -1,33 +1,33 @@
const yaml = require('js-yaml') const yaml = require('js-yaml');
const Beau = require('../beau') const Beau = require('../beau');
const { moduleVersion } = require('../shared') const { moduleVersion } = require('../shared');
jest.mock('../shared') jest.mock('../shared');
const requireg = require('requireg') const requireg = require('requireg');
requireg.resolving = false requireg.resolving = false;
describe(`Beau's config Loader.`, () => { describe(`Beau's config Loader.`, () => {
it('should load the config', () => { it('should load the config', () => {
moduleVersion.mockReturnValue(1) moduleVersion.mockReturnValue(1);
const doc = yaml.load(` const doc = yaml.safeLoad(`
version: 1 version: 1
endpoint: 'http://example.com' endpoint: 'http://example.com'
defaults: defaults:
headers: headers:
authentication: hello authentication: hello
`) `);
const beau = new Beau(doc) const beau = new Beau(doc);
expect(beau).toMatchSnapshot() expect(beau).toMatchSnapshot();
}) });
it(`should load the request list using the configuration`, () => { it(`should load the request list using the configuration`, () => {
moduleVersion.mockReturnValue(1) moduleVersion.mockReturnValue(1);
const doc = yaml.load(` const doc = yaml.safeLoad(`
version: 1 version: 1
endpoint: 'http://example.com' endpoint: 'http://example.com'
@ -36,24 +36,24 @@ describe(`Beau's config Loader.`, () => {
alias: user alias: user
headers: headers:
hello: world hello: world
`) `);
const beau = new Beau(doc) const beau = new Beau(doc);
expect(beau.requests).toMatchSnapshot() expect(beau.requests).toMatchSnapshot();
}) });
it('should display a warning if the module version and the beau file version are different', () => { it('should display a warning if the module version and the beau file version are different', () => {
let stdout let stdout;
let spy = jest let spy = jest
.spyOn(console, 'warn') .spyOn(console, 'warn')
.mockImplementation((val) => (stdout = val)) .mockImplementation(val => (stdout = val));
moduleVersion.mockReturnValue(2) moduleVersion.mockReturnValue(2);
const beau = new Beau({ version: 1 }) const beau = new Beau({ version: 1 });
expect(stdout).toEqual('This Beau file expects v1. You are using v2.') expect(stdout).toEqual('This Beau file expects v1. You are using v2.');
spy.mockReset() spy.mockReset();
spy.mockRestore() spy.mockRestore();
}) });
}) });

View File

@ -1,25 +1,25 @@
const yaml = require('js-yaml') const yaml = require('js-yaml');
const Config = require('../config') const Config = require('../config');
const requireg = require('requireg') const requireg = require('requireg');
requireg.resolving = false requireg.resolving = false;
describe('Config', () => { describe('Config', () => {
it('should load valid config keys', () => { it('should load valid config keys', () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
version: 1 version: 1
endpoint: http://martianwabbit.com endpoint: http://martianwabbit.com
shouldntBeAdded: true shouldntBeAdded: true
`) `);
const config = new Config(doc) const config = new Config(doc);
expect(config.ENDPOINT).toBe(doc.endpoint) expect(config.ENDPOINT).toBe(doc.endpoint);
expect(config.VERSION).toBe(doc.version) expect(config.VERSION).toBe(doc.version);
expect(config.shouldntBeAdded).toBeUndefined() expect(config.shouldntBeAdded).toBeUndefined();
}) });
it('should load requests', () => { it('should load requests', () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
endpoint: http://example.com endpoint: http://example.com
GET /profile: get-profile GET /profile: get-profile
@ -29,14 +29,14 @@ describe('Config', () => {
alias: user alias: user
headers: headers:
hello: world hello: world
`) `);
const config = new Config(doc) const config = new Config(doc);
expect(Object.keys(config.REQUESTS).length).toBe(4) expect(Object.keys(config.REQUESTS).length).toBe(4);
}) });
it('should set up defaults for all requests', () => { it('should set up defaults for all requests', () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
version: 1 version: 1
endpoint: 'http://example.com' endpoint: 'http://example.com'
@ -49,18 +49,18 @@ describe('Config', () => {
alias: user alias: user
headers: headers:
hello: world hello: world
`) `);
const config = new Config(doc) const config = new Config(doc);
expect(config).toMatchSnapshot() expect(config).toMatchSnapshot();
Object.values(config.REQUESTS).forEach((r) => { Object.values(config.REQUESTS).forEach(r => {
expect(r.HEADERS.authentication).toMatch('hello') expect(r.HEADERS.authentication).toMatch('hello');
}) });
}) });
it('should load multiple hosts', () => { it('should load multiple hosts', () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
version: 1 version: 1
endpoint: http://example.org endpoint: http://example.org
@ -97,15 +97,15 @@ describe('Config', () => {
endpoint: http://example.info endpoint: http://example.info
GET /posts: posts GET /posts: posts
`) `);
let config = new Config(doc) let config = new Config(doc);
expect(config).toMatchSnapshot() expect(config).toMatchSnapshot();
}) });
it('should namespace all aliases within an host', () => { it('should namespace all aliases within an host', () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
hosts: hosts:
- host: test1 - host: test1
endpoint: http://example.com endpoint: http://example.com
@ -113,16 +113,16 @@ describe('Config', () => {
- host: test2 - host: test2
endpoint: http://example.net endpoint: http://example.net
GET /posts: posts GET /posts: posts
`) `);
let config = new Config(doc) let config = new Config(doc);
expect(config.REQUESTS[0].ALIAS).toBe('test1:posts') expect(config.REQUESTS[0].ALIAS).toBe('test1:posts');
expect(config.REQUESTS[1].ALIAS).toBe('test2:posts') expect(config.REQUESTS[1].ALIAS).toBe('test2:posts');
}) });
it(`should throw if host doesn't have a host key`, () => { it(`should throw if host doesn't have a host key`, () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
hosts: hosts:
- endpoint: http://example.com - endpoint: http://example.com
GET /posts: posts GET /posts: posts
@ -130,13 +130,13 @@ describe('Config', () => {
- host: test2 - host: test2
endpoint: http://example.net endpoint: http://example.net
GET /posts: posts GET /posts: posts
`) `);
expect(() => new Config(doc)).toThrow() expect(() => new Config(doc)).toThrow();
}) });
it(`should merge host settings with global settings`, () => { it(`should merge host settings with global settings`, () => {
const doc = yaml.load(` const doc = yaml.safeLoad(`
defaults: defaults:
headers: headers:
hello: 1 hello: 1
@ -152,25 +152,9 @@ describe('Config', () => {
headers: false headers: false
GET /posts: posts GET /posts: posts
`) `);
let config = new Config(doc) let config = new Config(doc);
expect(config.REQUESTS[0].HEADERS.hello).toBe(1) expect(config.REQUESTS[0].HEADERS.hello).toBe(1);
}) });
});
it(`should allow different settings for the same request`, () => {
const doc = yaml.load(`
host: https://example.com
GET /1:
- alias: req1
headers:
request: 1
- alias: req2
headers:
request: 2
`)
let config = new Config(doc)
expect(config.REQUESTS.length).toBe(2)
})
})

View File

@ -1,41 +1,41 @@
const Plugins = require('../plugins') const Plugins = require('../plugins');
const Request = require('../request') const Request = require('../request');
const RequestCache = require('../requestCache') const RequestCache = require('../requestCache');
const requireg = require('requireg') const requireg = require('requireg');
describe(`Beau's plugin system`, () => { describe(`Beau's plugin system`, () => {
let request let request;
let plugins let plugins;
beforeEach(() => { beforeEach(() => {
plugins = new Plugins([{ Modifiers: [Object] }, 'DynamicValues'], []) plugins = new Plugins([{ Modifiers: [Object] }, 'DynamicValues'], []);
}) });
it('should load all plugins', () => { it('should load all plugins', () => {
expect(plugins.registry.preRequestModifiers.length).toBe(1) expect(plugins.registry.preRequestModifiers.length).toBe(1);
expect(plugins.registry.postRequestModifiers.length).toBe(1) expect(plugins.registry.postRequestModifiers.length).toBe(1);
expect(plugins.registry.dynamicValues.length).toBe(1) expect(plugins.registry.dynamicValues.length).toBe(1);
}) });
it(`should throw if given an invalid configuration`, () => { it(`should throw if given an invalid configuration`, () => {
expect(() => new Plugins([{ test1: true, test2: true }])).toThrow() expect(() => new Plugins([{ test1: true, test2: true }])).toThrow();
}) });
it(`shouldn't do anything when given an empty array.`, () => { it(`shouldn't do anything when given an empty array.`, () => {
expect(new Plugins([], [])).toMatchSnapshot() expect(new Plugins([], [])).toMatchSnapshot();
}) });
it(`should warn if the plugin is not available.`, () => { it(`should warn if the plugin is not available.`, () => {
const spy = jest.spyOn(console, 'warn').mockImplementation(() => {}) const spy = jest.spyOn(console, 'warn').mockImplementation(() => {});
requireg.resolving = false requireg.resolving = false;
new Plugins(['not-a-Package']) new Plugins(['not-a-Package']);
expect(spy).toHaveBeenCalled() expect(spy).toHaveBeenCalled();
requireg.resolving = true requireg.resolving = true;
spy.mockReset() spy.mockReset();
spy.mockRestore() spy.mockRestore();
}) });
describe(`Request Modifiers`, () => { describe(`Request Modifiers`, () => {
beforeEach(() => { beforeEach(() => {
@ -46,13 +46,13 @@ describe(`Beau's plugin system`, () => {
alias: 'update' alias: 'update'
}, },
plugins plugins
) );
}) });
it(`should modify the request and response using modifiers.`, async () => { it(`should modify the request and response using modifiers.`, async () => {
await expect(request.exec()).resolves.toMatchSnapshot() await expect(request.exec()).resolves.toMatchSnapshot();
}) });
}) });
describe(`Dynamic Values`, () => { describe(`Dynamic Values`, () => {
beforeEach(() => { beforeEach(() => {
@ -68,34 +68,34 @@ describe(`Beau's plugin system`, () => {
payload: 'counted $[add(1, $value2)] so far.' payload: 'counted $[add(1, $value2)] so far.'
}, },
plugins plugins
) );
}) });
let cache = new RequestCache() let cache = new RequestCache();
cache.add('value2', '2') cache.add('value2', '2');
it(`should look for dynamic values executing and replacing them`, async () => { it(`should look for dynamic values executing and replacing them`, async () => {
let req = await request.exec(cache) let req = await request.exec(cache);
expect(req).toHaveProperty('request.body', 'counted 3 so far.') expect(req).toHaveProperty('request.body', 'counted 3 so far.');
}) });
it(`should change the internal datatype if the only thing in the value is the dynamic value`, async () => { it(`should change the internal datatype if the only thing in the value is the dynamic value`, async () => {
let req = await request.exec(cache) let req = await request.exec(cache);
expect(req).toHaveProperty('request.headers.count', 3) expect(req).toHaveProperty('request.headers.count', 3);
}) });
it(`should return empty values as empty`, async () => { it(`should return empty values as empty`, async () => {
let req = await request.exec(cache) let req = await request.exec(cache);
expect(req).toHaveProperty('request.headers.empty', '') expect(req).toHaveProperty('request.headers.empty', '');
}) });
it(`should throw when calling an undefined dynamic value`, async () => { it(`should throw when calling an undefined dynamic value`, async () => {
request = new Request({ request = new Request({
request: 'POST /hello/$[notAvailable(1, 2)]', request: 'POST /hello/$[notAvailable(1, 2)]',
alias: 'say-hello' alias: 'say-hello'
}) });
await expect(request.exec()).rejects.toThrow() await expect(request.exec()).rejects.toThrow();
}) });
}) });
}) });

View File

@ -1,13 +1,13 @@
const Request = require('../request') const Request = require('../request');
const RequestCache = require('../requestCache') const RequestCache = require('../requestCache');
const requestPromiseNativeMock = require('request-promise-native') const requestPromiseNativeMock = require('request-promise-native');
describe('Request', () => { describe('Request', () => {
let cache let cache;
let validRequestConfig let validRequestConfig;
let invalidRequestConfig let invalidRequestConfig;
let request let request;
let requestWithoutDependencies let requestWithoutDependencies;
beforeEach(() => { beforeEach(() => {
validRequestConfig = { validRequestConfig = {
@ -23,78 +23,54 @@ describe('Request', () => {
payload: { payload: {
username: 'seich' username: 'seich'
} }
} };
invalidRequestConfig = { invalidRequestConfig = {
request: `POST /session`, request: `POST /session`,
endpoint: 'http://example.com' endpoint: 'http://example.com'
} };
cache = new RequestCache() cache = new RequestCache();
cache.add('session', { token: 'abc123' }) cache.add('session', { token: 'abc123' });
cache.add('profile', { UserId: 14 }) cache.add('profile', { UserId: 14 });
request = new Request(validRequestConfig) request = new Request(validRequestConfig);
requestWithoutDependencies = new Request({ requestWithoutDependencies = new Request({
endpoint: 'http://example.com', endpoint: 'http://example.com',
request: 'GET /user', request: 'GET /user',
alias: 'show' alias: 'show'
}) });
requestPromiseNativeMock.fail = false requestPromiseNativeMock.fail = false;
}) });
it('should load up the given request', () => { it('should load up the given request', () => {
expect(request.VERB).toBe('POST') expect(request.VERB).toBe('POST');
expect(request.ENDPOINT).toBe(validRequestConfig.endpoint) expect(request.ENDPOINT).toBe(validRequestConfig.endpoint);
expect(request.HEADERS).toBeDefined() expect(request.HEADERS).toBeDefined();
expect(request.PAYLOAD).toBeDefined() expect(request.PAYLOAD).toBeDefined();
expect(request.PARAMS).toBeDefined() expect(request.PARAMS).toBeDefined();
}) });
it('should throw if a given request is invalid', () => { it('should throw if a given request is invalid', () => {
expect(() => new Request(invalidRequestConfig)).toThrow() expect(() => new Request(invalidRequestConfig)).toThrow();
}) });
it('should list all of its dependencies', () => { it('should list all of its dependencies', () => {
expect(request.DEPENDENCIES.size).toBe(2) expect(request.DEPENDENCIES.size).toBe(2);
expect(request.DEPENDENCIES).toContain('session') expect(request.DEPENDENCIES).toContain('session');
expect(request.DEPENDENCIES).toContain('profile') expect(request.DEPENDENCIES).toContain('profile');
}) });
it('should execute a request', async () => { it('should execute a request', async () => {
await expect(request.exec(cache)).resolves.toMatchSnapshot() await expect(request.exec(cache)).resolves.toMatchSnapshot();
await expect( await expect(
requestWithoutDependencies.exec() requestWithoutDependencies.exec()
).resolves.toMatchSnapshot() ).resolves.toMatchSnapshot();
}) });
it('should throw if the request fails', async () => { it('should throw if the request fails', async () => {
requestPromiseNativeMock.fail = true requestPromiseNativeMock.fail = true;
await expect(requestWithoutDependencies.exec()).rejects.toThrow(Error) await expect(requestWithoutDependencies.exec()).rejects.toThrow(Error);
}) });
});
it(`should use the full url if given one as part of the path instead of the global endpoint`, async () => {
const requestWithPath = new Request({
endpoint: 'http://example.com',
request: 'GET http://martianwabbit.com/user',
alias: 'get-user'
})
const requestWithoutPath = new Request({
endpoint: 'http://example.com',
request: 'GET /user',
alias: 'get-user'
})
await expect(requestWithPath.exec()).resolves.toHaveProperty(
'request.endpoint',
'http://martianwabbit.com/user'
)
await expect(requestWithoutPath.exec()).resolves.toHaveProperty(
'request.endpoint',
'http://example.com/user'
)
})
})

View File

@ -1,14 +1,14 @@
const RequestCache = require('../requestCache') const RequestCache = require('../requestCache');
describe('Request Cache', () => { describe('Request Cache', () => {
let cache let cache;
beforeEach(() => { beforeEach(() => {
cache = new RequestCache() cache = new RequestCache();
cache.add('session', { cache.add('session', {
hello: 'World' hello: 'World'
}) });
cache.add('array', [ cache.add('array', [
{ {
@ -19,36 +19,36 @@ describe('Request Cache', () => {
id: 2, id: 2,
name: 'Angela' name: 'Angela'
} }
]) ]);
}) });
it('should add keys to the cache', () => { it('should add keys to the cache', () => {
expect(cache.$cache.session.hello).toBe('World') expect(cache.$cache.session.hello).toBe('World');
}) });
describe('get', () => { describe('get', () => {
it('should be able to find key values with a given path', () => { it('should be able to find key values with a given path', () => {
expect(cache.get('session.hello')).toBe('World') expect(cache.get('session.hello')).toBe('World');
}) });
it('should throw when given an invalid path', () => { it('should throw when given an invalid path', () => {
expect(() => cache.get('$session.world')).toThrow() expect(() => cache.get('$session.world')).toThrow();
}) });
}) });
describe('parse', () => { describe('parse', () => {
it("should transform variables in strings using it's cache", () => { it("should transform variables in strings using it's cache", () => {
expect(cache.parse('Hello $session.hello')).toBe('Hello World') expect(cache.parse('Hello $session.hello')).toBe('Hello World');
}) });
it('should go transform variables in all values when given an object', () => { it('should go transform variables in all values when given an object', () => {
let parsed = cache.parse({ let parsed = cache.parse({
hello: 'hello $session.hello', hello: 'hello $session.hello',
earth: '$session.hello' earth: '$session.hello'
}) });
expect(parsed.hello).toBe('hello World') expect(parsed.hello).toBe('hello World');
expect(parsed.earth).toBe('World') expect(parsed.earth).toBe('World');
}) });
it('should return every non-string value as-is', () => { it('should return every non-string value as-is', () => {
let parsed = cache.parse({ let parsed = cache.parse({
@ -56,34 +56,34 @@ describe('Request Cache', () => {
nulled: null, nulled: null,
truthy: false, truthy: false,
hello: '$session.hello' hello: '$session.hello'
}) });
expect(parsed.number).toBe(1) expect(parsed.number).toBe(1);
expect(parsed.nulled).toBeNull() expect(parsed.nulled).toBeNull();
expect(parsed.truthy).toBe(false) expect(parsed.truthy).toBe(false);
expect(parsed.hello).toBe('World') expect(parsed.hello).toBe('World');
}) });
it('should parse arrays as well', () => { it('should parse arrays as well', () => {
let parsed = cache.parse({ hello: '$array.0.name' }) let parsed = cache.parse({ hello: '$array.0.name' });
expect(parsed.hello).toBe('Sergio') expect(parsed.hello).toBe('Sergio');
}) });
it('should return an object when given an undefined value', () => { it('should return an object when given an undefined value', () => {
expect(cache.parse(undefined)).toEqual({}) expect(cache.parse(undefined)).toEqual({});
}) });
it('should parse any value other than undefined', () => { it('should parse any value other than undefined', () => {
expect(cache.parse('Hello $session.hello')).toBe('Hello World') expect(cache.parse('Hello $session.hello')).toBe('Hello World');
}) });
it('should return null when passed null', () => { it('should return null when passed null', () => {
expect(cache.parse(null)).toBe(null) expect(cache.parse(null)).toBe(null);
}) });
it(`shouldn't replace escaped variables`, () => { it(`shouldn't replace escaped variables`, () => {
expect(cache.parse(`\\$session.hello is $session.hello`)).toBe( expect(cache.parse(`\\$session.hello is $session.hello`)).toBe(
`$session.hello is World` `$session.hello is World`
) );
}) });
}) });
}) });

View File

@ -1,13 +1,13 @@
const Config = require('../config') const Config = require('../config');
const RequestList = require('../requestList') const RequestList = require('../requestList');
const requestPromiseNativeMock = require('request-promise-native') const requestPromiseNativeMock = require('request-promise-native');
describe('RequestList', () => { describe('RequestList', () => {
const endpoint = 'http://martianwabbit.com' const endpoint = 'http://martianwabbit.com';
let env = { let env = {
environmental: true environmental: true
} };
const doc = { const doc = {
ENDPOINT: endpoint, ENDPOINT: endpoint,
@ -20,49 +20,49 @@ describe('RequestList', () => {
lastname: 'Diaz' lastname: 'Diaz'
} }
} }
} };
let requests let requests;
beforeEach(() => { beforeEach(() => {
requestPromiseNativeMock.fail = false requestPromiseNativeMock.fail = false;
let config = new Config(doc) let config = new Config(doc);
requests = new RequestList(config) requests = new RequestList(config);
}) });
it('should allow an empty request list', () => { it('should allow an empty request list', () => {
requests = new RequestList() requests = new RequestList();
expect(requests.list.length).toBe(0) expect(requests.list.length).toBe(0);
}) });
it('should load valid requests', () => { it('should load valid requests', () => {
expect(requests.list.length).toBe(2) expect(requests.list.length).toBe(2);
}) });
it('should fetch dependencies', async () => { it('should fetch dependencies', async () => {
await expect( await expect(
requests.fetchDependencies(['get-posts']) requests.fetchDependencies(['get-posts'])
).resolves.toMatchSnapshot() ).resolves.toMatchSnapshot();
}) });
it('should execute requests by alias.', async () => { it('should execute requests by alias.', async () => {
await expect(requests.execByAlias('user')).resolves.toMatchSnapshot() await expect(requests.execByAlias('user')).resolves.toMatchSnapshot();
}) });
it('should fail if the request fails', async () => { it('should fail if the request fails', async () => {
requestPromiseNativeMock.fail = true requestPromiseNativeMock.fail = true;
await expect(requests.execByAlias('user')).rejects.toThrow() await expect(requests.execByAlias('user')).rejects.toThrow();
}) });
it('should return a cached result if available', async () => { it('should return a cached result if available', async () => {
const obj = { test: true } const obj = { test: true };
requests.cache.add('test', obj) requests.cache.add('test', obj);
await expect(requests.execByAlias('test')).resolves.toBe(obj) await expect(requests.execByAlias('test')).resolves.toBe(obj);
}) });
it('should fail if the alias is not found', async () => { it('should fail if the alias is not found', async () => {
await expect(requests.execByAlias('notAnAlias')).rejects.toThrow() await expect(requests.execByAlias('notAnAlias')).rejects.toThrow();
}) });
it(`should fail if a given request doesn't have an alias`, () => { it(`should fail if a given request doesn't have an alias`, () => {
let config = new Config({ let config = new Config({
@ -71,8 +71,8 @@ describe('RequestList', () => {
hello: 1 hello: 1
} }
} }
}) });
expect(() => new RequestList(config, config)).toThrow() expect(() => new RequestList(config, config)).toThrow();
}) });
}) });

View File

@ -0,0 +1,15 @@
const schema = require('../schema');
describe('Schema', () => {
it(`should validate an object against the schema`, async () => {
await expect(
schema.validate({ endpoint: 'http://example.com' })
).resolves.toHaveProperty('valid', true);
});
it(`should indicate the error when an schema is invalid`, async () => {
await expect(
schema.validate({ plugins: [{ hello: 1, world: 2 }] })
).resolves.toHaveProperty('valid', false);
});
});

View File

@ -5,9 +5,8 @@ const {
UpperCaseKeys, UpperCaseKeys,
removeOptionalKeys, removeOptionalKeys,
toKebabCase, toKebabCase,
replaceInObject, replaceInObject
expandPath } = require('../shared');
} = require('../shared')
describe('Shared Utilities', () => { describe('Shared Utilities', () => {
describe('requestRegex', () => { describe('requestRegex', () => {
@ -22,9 +21,9 @@ describe('Shared Utilities', () => {
['TRACE /hello', true], ['TRACE /hello', true],
['PATCH /hello', true] ['PATCH /hello', true]
])('should match: %s', (example, expected) => { ])('should match: %s', (example, expected) => {
expect(requestRegex.test(example)).toBe(expected) expect(requestRegex.test(example)).toBe(expected);
}) });
}) });
describe('replacementRegex', () => { describe('replacementRegex', () => {
test.each([ test.each([
@ -33,9 +32,9 @@ describe('Shared Utilities', () => {
['PUT /hi/$a.a/$a.b', ['$a.a', '$a.b']], ['PUT /hi/$a.a/$a.b', ['$a.a', '$a.b']],
[`\\$value`, ['\\$value']] [`\\$value`, ['\\$value']]
])('should match: %s', (example, expected) => { ])('should match: %s', (example, expected) => {
expect(example.match(replacementRegex)).toEqual(expected) expect(example.match(replacementRegex)).toEqual(expected);
}) });
}) });
describe('dynamicValueRegex', () => { describe('dynamicValueRegex', () => {
test.each([ test.each([
@ -43,56 +42,34 @@ describe('Shared Utilities', () => {
['$[test(1, 2, 3)]', ['$[test(1, 2, 3)]']], ['$[test(1, 2, 3)]', ['$[test(1, 2, 3)]']],
[`$[test({ \n id: 1 \n })]`, ['$[test({ \n id: 1 \n })]']] [`$[test({ \n id: 1 \n })]`, ['$[test({ \n id: 1 \n })]']]
])('should match: %s', (example, expected) => { ])('should match: %s', (example, expected) => {
expect(example.match(dynamicValueRegex)).toEqual(expected) expect(example.match(dynamicValueRegex)).toEqual(expected);
}) });
}) });
describe('UpperCaseKeys', () => { describe('UpperCaseKeys', () => {
it('should uppercase all first-level keys in an object', () => { it('should uppercase all first-level keys in an object', () => {
let a = { test: 1, Test2: 2 } let a = { test: 1, Test2: 2 };
expect(UpperCaseKeys(a)).toEqual({ TEST: 1, TEST2: 2 }) expect(UpperCaseKeys(a)).toEqual({ TEST: 1, TEST2: 2 });
}) });
}) });
describe('removeOptionalKeys', () => { describe('removeOptionalKeys', () => {
it('should remove empty objects from an object', () => { it('should remove empty objects from an object', () => {
let a = { b: {}, c: 2, d: {} } let a = { b: {}, c: 2, d: {} };
expect(removeOptionalKeys(a, ['b', 'd'])).toEqual({ c: 2 }) expect(removeOptionalKeys(a, ['b', 'd'])).toEqual({ c: 2 });
}) });
}) });
describe('toKebabCase', () => { describe('toKebabCase', () => {
it('should convert camel case to kebab case', () => { it('should convert camel case to kebab case', () => {
expect(toKebabCase('helloWorld')).toBe('hello-world') expect(toKebabCase('helloWorld')).toBe('hello-world');
}) });
}) });
describe('replaceInObject', () => { describe('replaceInObject', () => {
it('should replace every value in an object with the output of a function', () => { it('should replace every value in an object with the output of a function', () => {
let a = { b: 'b', c: 'c' } let a = { b: 'b', c: 'c' };
expect(replaceInObject(a, (obj) => 'a')).toEqual({ b: 'a', c: 'a' }) expect(replaceInObject(a, obj => 'a')).toEqual({ b: 'a', c: 'a' });
}) });
}) });
});
describe('expandPath', () => {
test.each([
['https://alchem.ee', 'api/v1/hello'],
['https://alchem.ee/', '/api/v1/hello'],
['https://alchem.ee', '/api/v1/hello'],
['https://alchem.ee/', 'api/v1/hello']
])(
'should add a base url to the path is the path is not a url: %s, %s',
(url, path) => {
expect(expandPath(url, path)).toEqual(
'https://alchem.ee/api/v1/hello'
)
}
)
it('should return the path if its a fully fledged url on its own', () => {
expect(
expandPath('https://alchem.ee', 'https://martianwabbit.com')
).toEqual('https://martianwabbit.com')
})
})
})

View File

@ -1,20 +1,20 @@
const RequestList = require('./requestList') const RequestList = require('./requestList');
const Config = require('./config') const Config = require('./config');
const { moduleVersion } = require('./shared') const { moduleVersion } = require('./shared');
class Beau { class Beau {
constructor(doc, env = {}) { constructor(doc, env = {}) {
this.config = new Config(doc, env) this.config = new Config(doc, env);
this.requests = new RequestList(this.config) this.requests = new RequestList(this.config);
if (this.config.VERSION !== moduleVersion()) { if (this.config.VERSION !== moduleVersion()) {
console.warn( console.warn(
`This Beau file expects v${ `This Beau file expects v${
this.config.VERSION this.config.VERSION
}. You are using v${moduleVersion()}.` }. You are using v${moduleVersion()}.`
) );
} }
} }
} }
module.exports = Beau module.exports = Beau;

View File

@ -1,6 +1,6 @@
const deepMerge = require('deepmerge') const deepMerge = require('deepmerge');
const { requestRegex, UpperCaseKeys, moduleVersion } = require('./shared') const { requestRegex, UpperCaseKeys, moduleVersion } = require('./shared');
const Plugins = require('./plugins') const Plugins = require('./plugins');
class Config { class Config {
constructor(doc, env = {}) { constructor(doc, env = {}) {
@ -12,89 +12,81 @@ class Config {
ENVIRONMENT: {}, ENVIRONMENT: {},
HOSTS: [], HOSTS: [],
COOKIEJAR: false COOKIEJAR: false
} };
this.configKeys = Object.keys(defaultConfigValues) this.configKeys = Object.keys(defaultConfigValues);
let config = this.loadConfig(doc) let config = this.loadConfig(doc);
Object.assign(this, defaultConfigValues, config) Object.assign(this, defaultConfigValues, config);
this.ENVIRONMENT = deepMerge(this.ENVIRONMENT, env) this.ENVIRONMENT = deepMerge(this.ENVIRONMENT, env);
this.REQUESTS = [] this.REQUESTS = [];
this.loadRequests(doc, { this.loadRequests(doc, {
DEFAULTS: this.DEFAULTS, DEFAULTS: this.DEFAULTS,
ENDPOINT: this.ENDPOINT ENDPOINT: this.ENDPOINT
}) });
this.loadHosts(this.HOSTS, config, defaultConfigValues) this.loadHosts(this.HOSTS, config, defaultConfigValues);
this.PLUGINS = new Plugins(this.PLUGINS) this.PLUGINS = new Plugins(this.PLUGINS);
} }
loadHosts(hosts, rootConfig, defaultConfigValues) { loadHosts(hosts, rootConfig, defaultConfigValues) {
hosts.forEach((host) => { hosts.forEach(host => {
if (typeof host.host === 'undefined') { if (typeof host.host === 'undefined') {
throw new Error(`Host doesn't indicate it's host name.`) throw new Error(`Host doesn't indicate it's host name.`);
} }
let config = deepMerge(defaultConfigValues, this.loadConfig(host)) let config = deepMerge(defaultConfigValues, this.loadConfig(host));
config.DEFAULTS = deepMerge(rootConfig.DEFAULTS, config.DEFAULTS) config.DEFAULTS = deepMerge(rootConfig.DEFAULTS, config.DEFAULTS);
this.loadRequests(host, { this.loadRequests(host, {
DEFAULTS: config.DEFAULTS, DEFAULTS: config.DEFAULTS,
ENDPOINT: config.ENDPOINT, ENDPOINT: config.ENDPOINT,
NAMESPACE: host.host NAMESPACE: host.host
}) });
}) });
} }
loadRequests(host, settings) { loadRequests(host, settings) {
Object.entries(host) let requests = Object.keys(host)
.filter(([key]) => requestRegex.test(key)) .filter(key => requestRegex.test(key))
.forEach(([key, rDefinition]) => { .map(key => {
if (Array.isArray(rDefinition)) { let requestDefinitionIsString = typeof host[key] === 'string';
rDefinition.forEach((req) => let originalRequest = requestDefinitionIsString
this.addRequest(key, req, settings) ? { ALIAS: host[key] }
) : host[key];
} else {
this.addRequest(key, rDefinition, settings) let request = UpperCaseKeys(originalRequest);
if (settings.NAMESPACE) {
request.ALIAS = `${settings.NAMESPACE}:${request.ALIAS}`;
} }
})
}
addRequest(key, rDefinition, settings) { request.REQUEST = key;
let requestDefinitionIsString = typeof rDefinition === 'string' request.COOKIEJAR = this.COOKIEJAR;
let originalRequest = requestDefinitionIsString request.ENDPOINT = settings.ENDPOINT;
? { ALIAS: rDefinition }
: rDefinition
let request = UpperCaseKeys(originalRequest) let defaults = UpperCaseKeys(settings.DEFAULTS);
if (settings.NAMESPACE) { return deepMerge(defaults, request);
request.ALIAS = `${settings.NAMESPACE}:${request.ALIAS}` });
}
request.REQUEST = key this.REQUESTS = this.REQUESTS.concat(requests);
request.COOKIEJAR = this.COOKIEJAR
request.ENDPOINT = settings.ENDPOINT
let defaults = UpperCaseKeys(settings.DEFAULTS)
this.REQUESTS.push(deepMerge(defaults, request))
} }
loadConfig(host) { loadConfig(host) {
let config = {} let config = {};
Object.entries(host) Object.keys(host)
.filter(([key]) => this.configKeys.includes(key.toUpperCase())) .filter(k => this.configKeys.includes(k.toUpperCase()))
.forEach(([key, value]) => (config[key.toUpperCase()] = value)) .forEach(k => (config[k.toUpperCase()] = host[k]));
return config return config;
} }
} }
module.exports = Config module.exports = Config;

View File

@ -1,8 +1,8 @@
const vm = require('vm') const vm = require('vm');
const requireg = require('requireg') const requireg = require('requireg');
const deepmerge = require('deepmerge') const deepmerge = require('deepmerge');
const { toKebabCase, dynamicValueRegex, replaceInObject } = require('./shared') const { toKebabCase, dynamicValueRegex, replaceInObject } = require('./shared');
const { isPlainObject } = require('is-plain-object') const isPlainObject = require('is-plain-object');
class Plugins { class Plugins {
constructor(plugins = [], autoload = ['std']) { constructor(plugins = [], autoload = ['std']) {
@ -10,110 +10,112 @@ class Plugins {
preRequestModifiers: [], preRequestModifiers: [],
postRequestModifiers: [], postRequestModifiers: [],
dynamicValues: [] dynamicValues: []
} };
this.context = {} this.context = {};
this.autoload = autoload this.autoload = autoload;
this.loadPlugins(plugins.concat(this.autoload)) this.loadPlugins(plugins.concat(this.autoload));
} }
normalizePlugins(plugins) { normalizePlugins(plugins) {
let results = {} let results = {};
plugins.forEach((plugin) => { plugins.forEach(plugin => {
let name = plugin let name = plugin;
let settings = undefined let settings = undefined;
if (typeof plugin === 'object') { if (typeof plugin === 'object') {
let keys = Object.keys(plugin) let keys = Object.keys(plugin);
if (keys.length !== 1) { if (keys.length !== 1) {
throw new Error(`Plugin items should contain only one key.`) throw new Error(
`Plugin items should contain only one key.`
);
} }
name = keys[0] name = keys[0];
settings = plugin[name] settings = plugin[name];
} }
results[name] = settings results[name] = settings;
}) });
return results return results;
} }
loadPlugins(plugins) { loadPlugins(plugins) {
plugins = this.normalizePlugins(plugins) plugins = this.normalizePlugins(plugins);
Object.keys(plugins).forEach((name) => { Object.keys(plugins).forEach(name => {
const module = `beau-${toKebabCase(name)}` const module = `beau-${toKebabCase(name)}`;
if (typeof requireg.resolve(module) !== 'undefined') { if (typeof requireg.resolve(module) !== 'undefined') {
const plugin = requireg(module) const plugin = requireg(module);
new plugin(this, plugins[name]) new plugin(this, plugins[name]);
} else { } else {
if (this.autoload.includes(name)) return if (this.autoload.includes(name)) return;
console.warn( console.warn(
`Plugin ${name} couldn't be found. It is available globally?` `Plugin ${name} couldn't be found. It is available globally?`
) );
} }
}) });
} }
executeModifier(modifier, obj, orig) { executeModifier(modifier, obj, orig) {
let result = deepmerge({}, obj, { isMergeableObject: isPlainObject }) let result = deepmerge({}, obj, { isMergeableObject: isPlainObject });
this.registry[modifier].forEach( this.registry[modifier].forEach(
(modifier) => (result = modifier(result, orig)) modifier => (result = modifier(result, orig))
) );
return result return result;
} }
replaceDynamicValues(obj) { replaceDynamicValues(obj) {
vm.createContext(this.context) vm.createContext(this.context);
return replaceInObject(obj, (val) => { return replaceInObject(obj, val => {
let valIsEmpty = val.trim().length === 0 let valIsEmpty = val.trim().length === 0;
if (valIsEmpty) { if (valIsEmpty) {
return val return val;
} }
try { try {
let onlyHasDynamic = let onlyHasDynamic =
val.replace(dynamicValueRegex, '').trim() === '' val.replace(dynamicValueRegex, '').trim() === '';
if (onlyHasDynamic) { if (onlyHasDynamic) {
let call let call;
val.replace(dynamicValueRegex, (match, c) => { val.replace(dynamicValueRegex, (match, c) => {
call = c call = c;
}) });
return vm.runInContext(call, this.context) return vm.runInContext(call, this.context);
} }
return val.replace(dynamicValueRegex, (match, call) => { return val.replace(dynamicValueRegex, (match, call) => {
return vm.runInContext(call, this.context) return vm.runInContext(call, this.context);
}) });
} catch (e) { } catch (e) {
throw new Error(`DynamicValue: ` + e) throw new Error(`DynamicValue: ` + e);
} }
}) });
} }
addPreRequestModifier(modifier) { addPreRequestModifier(modifier) {
this.registry.preRequestModifiers.push(modifier) this.registry.preRequestModifiers.push(modifier);
} }
addPostRequestModifier(modifier) { addPostRequestModifier(modifier) {
this.registry.postRequestModifiers.push(modifier) this.registry.postRequestModifiers.push(modifier);
} }
defineDynamicValue(name, fn) { defineDynamicValue(name, fn) {
this.registry.dynamicValues.push({ name, fn }) this.registry.dynamicValues.push({ name, fn });
this.context[name] = fn this.context[name] = fn;
} }
} }
module.exports = Plugins module.exports = Plugins;

View File

@ -1,71 +1,70 @@
const request = require('request-promise-native') const request = require('request-promise-native');
const RequestCache = require('./requestCache') const RequestCache = require('./requestCache');
const Plugins = require('./plugins') const Plugins = require('./plugins');
const { const {
requestRegex, requestRegex,
replacementRegex, replacementRegex,
UpperCaseKeys, UpperCaseKeys,
removeOptionalKeys, removeOptionalKeys
isUrl } = require('./shared');
} = require('./shared')
class Request { class Request {
constructor(req, plugins = new Plugins()) { constructor(req, plugins = new Plugins()) {
this.originalRequest = req this.originalRequest = req;
this.plugins = plugins this.plugins = plugins;
req = UpperCaseKeys(req) req = UpperCaseKeys(req);
Object.assign(this, req) Object.assign(this, req);
if (!this.ALIAS) { if (!this.ALIAS) {
throw new Error(`${this.REQUEST} is missing an alias.`) throw new Error(`${this.REQUEST} is missing an alias.`);
} }
const { VERB, PATH } = this.parseRequest(this.REQUEST) const { VERB, PATH } = this.parseRequest(this.REQUEST);
this.VERB = VERB this.VERB = VERB;
this.PATH = PATH this.PATH = PATH;
this.DEPENDENCIES = this.findDependencies(req) this.DEPENDENCIES = this.findDependencies(req);
} }
parseRequest(request) { parseRequest(request) {
const parts = request.match(requestRegex) const parts = request.match(requestRegex);
return { return {
VERB: parts[1], VERB: parts[1],
PATH: parts[2] PATH: parts[2]
} };
} }
findDependencies(request, set = new Set()) { findDependencies(request, set = new Set()) {
let type = typeof request let type = typeof request;
if (type === 'object' && request !== null) { if (type === 'object') {
Object.entries(request) Object.keys(request)
.filter(([key]) => key !== 'ALIAS') .filter(key => key !== 'ALIAS')
.forEach(([key, value]) => { .forEach(key => {
set = this.findDependencies(value, set) set = this.findDependencies(request[key], set);
}) });
} else if (type === 'string') { } else if (type === 'string') {
const matches = [] const matches = [];
request.replace( request.replace(
replacementRegex, replacementRegex,
(match, g1) => !match.startsWith('\\') && matches.push(g1) (match, g1) => !match.startsWith('\\') && matches.push(g1)
) );
const deps = matches.map((m) => m.split('.')[0]) const deps = matches.map(m => m.split('.')[0]);
return new Set([...set, ...deps]) return new Set([...set, ...deps]);
} }
return set return set;
} }
async exec(cache = new RequestCache()) { async exec(cache = new RequestCache()) {
let settings = cache.parse({ let settings = cache.parse({
baseUrl: '', baseUrl: this.ENDPOINT,
uri: this.PATH, uri: this.PATH,
method: this.VERB, method: this.VERB,
jar: this.COOKIEJAR, jar: this.COOKIEJAR,
@ -79,10 +78,7 @@ class Request {
json: true, json: true,
simple: false, simple: false,
resolveWithFullResponse: true resolveWithFullResponse: true
}) });
const isPathFullUrl = isUrl(settings.uri)
settings.baseUrl = isPathFullUrl ? '' : this.ENDPOINT
settings = removeOptionalKeys(settings, [ settings = removeOptionalKeys(settings, [
'headers', 'headers',
@ -90,17 +86,17 @@ class Request {
'body', 'body',
'form', 'form',
'formData' 'formData'
]) ]);
settings = this.plugins.replaceDynamicValues(settings) settings = this.plugins.replaceDynamicValues(settings);
settings = this.plugins.executeModifier( settings = this.plugins.executeModifier(
'preRequestModifiers', 'preRequestModifiers',
settings, settings,
this.originalRequest this.originalRequest
) );
const response = await request(settings) const response = await request(settings);
let results = { let results = {
request: { request: {
@ -114,18 +110,18 @@ class Request {
body: response.body body: response.body
}, },
body: response.body body: response.body
} };
results = this.plugins.executeModifier( results = this.plugins.executeModifier(
'postRequestModifiers', 'postRequestModifiers',
results, results,
this.originalRequest this.originalRequest
) );
cache.add(this.ALIAS, results) cache.add(this.ALIAS, results);
return results return results;
} }
} }
module.exports = Request module.exports = Request;

View File

@ -1,46 +1,46 @@
const { replacementRegex, replaceInObject } = require('./shared') const { replacementRegex, replaceInObject } = require('./shared');
class RequestCache { class RequestCache {
constructor() { constructor() {
this.$cache = {} this.$cache = {};
} }
exists(key) { exists(key) {
return typeof this.$cache[key] !== 'undefined' return typeof this.$cache[key] !== 'undefined';
} }
add(key, value) { add(key, value) {
this.$cache[key] = value this.$cache[key] = value;
} }
get(path) { get(path) {
let result = this.$cache let result = this.$cache;
path.split('.').forEach((part) => { path.split('.').forEach(part => {
if (result[part] === undefined) { if (result[part] === undefined) {
throw new Error(`${path} not found in cache.`) throw new Error(`${path} not found in cache.`);
} }
result = result[part] result = result[part];
}) });
return result return result;
} }
parse(item) { parse(item) {
if (item === null) { if (item === null) {
return null return null;
} }
return replaceInObject(item, (item) => return replaceInObject(item, item =>
item.replace(replacementRegex, (match, key) => { item.replace(replacementRegex, (match, key) => {
if (match.startsWith('\\')) { if (match.startsWith('\\')) {
return match.replace('\\$', '$') return match.replace('\\$', '$');
} }
return this.get(key) return this.get(key);
}) })
) );
} }
} }
module.exports = RequestCache module.exports = RequestCache;

View File

@ -1,54 +1,59 @@
const Request = require('./request') const Request = require('./request');
const RequestCache = require('./requestCache') const RequestCache = require('./requestCache');
class RequestList { class RequestList {
constructor(config = { REQUESTS: [] }) { constructor(config = { REQUESTS: [] }) {
this.list = this.loadRequests(config.REQUESTS, config.PLUGINS) this.PLUGINS = config.PLUGINS;
this.cache = new RequestCache() this.REQUESTS = config.REQUESTS;
this.cache.add(`env`, config.ENVIRONMENT) this.list = this.loadRequests();
this.cache = new RequestCache();
this.cache.add(`env`, config.ENVIRONMENT);
} }
async execByAlias(alias) { async execByAlias(alias) {
if (this.cache.exists(alias)) { if (this.cache.exists(alias)) {
return this.cache.get(alias) return this.cache.get(alias);
} }
const request = this.list.find((r) => r.ALIAS === alias) const request = this.list.find(r => r.ALIAS === alias);
if (typeof request === 'undefined') { if (typeof request === 'undefined') {
throw new Error(`${alias} not found among the requests.`) throw new Error(`${alias} not found among the requests.`);
} }
try { try {
await this.fetchDependencies(Array.from(request.DEPENDENCIES)) await this.fetchDependencies(Array.from(request.DEPENDENCIES));
return await request.exec(this.cache) return await request.exec(this.cache);
} catch (reason) { } catch (reason) {
throw new Error( throw new Error(
`Request ${request.VERB} ${request.ENDPOINT} FAILED. \n${reason}` `Request ${request.VERB} ${
) request.ENDPOINT
} FAILED. \n${reason}`
);
} }
} }
async fetchDependencies(dependencies) { async fetchDependencies(dependencies) {
dependencies = dependencies.map((d) => this.execByAlias(d)) dependencies = dependencies.map(d => this.execByAlias(d));
await Promise.all(dependencies) await Promise.all(dependencies);
return this.cache return this.cache;
} }
loadRequests(REQUESTS, PLUGINS) { loadRequests() {
let requests = [] let requests = [];
REQUESTS.forEach((request) => { this.REQUESTS.forEach(request => {
try { try {
requests.push(new Request(request, PLUGINS)) requests.push(new Request(request, this.PLUGINS));
} catch (e) { } catch (e) {
throw new Error(`${request.request} was ignored: ${e}`) throw new Error(`${request.request} was ignored: ${e}`);
} }
}) });
return requests return requests;
} }
} }
module.exports = RequestList module.exports = RequestList;

77
src/schema.js Normal file
View File

@ -0,0 +1,77 @@
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 {
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 };

View File

@ -1,5 +1,3 @@
const { URL } = require('url')
const httpVerbs = [ const httpVerbs = [
'GET', 'GET',
'HEAD', 'HEAD',
@ -10,80 +8,66 @@ const httpVerbs = [
'OPTIONS', 'OPTIONS',
'TRACE', 'TRACE',
'PATCH' 'PATCH'
] ];
const requestRegex = new RegExp(`(${httpVerbs.join('|')})\\s(.*)`, 'i') const requestRegex = new RegExp(`(${httpVerbs.join('|')})\\s(.*)`, 'i');
const replacementRegex = /(?:\\?)\$([a-zA-Z\.\d\-\_\:]+)/g const replacementRegex = /(?:\\?)\$([a-zA-Z\.\d\-\_\:]+)/g;
const dynamicValueRegex = /\$\[(\w+\((?:.|[\n\r])*?\))\]/g const dynamicValueRegex = /\$\[(\w+\((?:.|[\n\r])*?\))\]/g;
const UpperCaseKeys = function (obj) { const UpperCaseKeys = function(obj) {
let result = {} let result = {};
Object.entries(obj).forEach(([k, v]) => (result[k.toUpperCase()] = v)) Object.keys(obj).forEach(k => (result[k.toUpperCase()] = obj[k]));
return result return result;
} };
const isEmptyObject = (obj) => const isEmptyObject = obj =>
Object.keys(obj).length === 0 && obj.constructor === Object Object.keys(obj).length === 0 && obj.constructor === Object;
const removeOptionalKeys = function (obj, optionalValues) { const removeOptionalKeys = function(obj, optionalValues) {
let result = {} let result = {};
Object.entries(obj).forEach(([key, value]) => { Object.keys(obj).forEach(key => {
if (optionalValues.includes(key) && isEmptyObject(value)) { if (optionalValues.includes(key) && isEmptyObject(obj[key])) {
return return;
} }
result[key] = value result[key] = obj[key];
}) });
return result return result;
} };
const toKebabCase = function (str) { const toKebabCase = function(str) {
return str return str
.trim() .trim()
.replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/([a-z])([A-Z])/g, '$1-$2')
.toLowerCase() .toLowerCase();
} };
const replaceInObject = function (obj, fn) { const replaceInObject = function(obj, fn) {
if (obj === null) { if (obj === null) {
return null return null;
} }
switch (typeof obj) { let type = typeof obj;
case 'undefined':
return {}
case 'string':
return fn(obj)
case 'object':
obj = Object.assign({}, obj)
Object.entries(obj).forEach(
([key, value]) => (obj[key] = replaceInObject(value, fn))
)
default:
return obj
}
}
const moduleVersion = () => parseInt(require('../package.json').version, 10) if (type === 'undefined') {
return {};
const isUrl = function (str) {
try {
new URL(str)
return true
} catch (e) {
return false
}
}
const expandPath = (url, path) => {
if (isUrl(path)) {
return path
} }
return url.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '') if (type === 'string') {
} return fn(obj);
}
if (type === 'object') {
obj = Object.assign({}, obj);
Object.keys(obj).forEach(k => (obj[k] = replaceInObject(obj[k], fn)));
}
return obj;
};
const moduleVersion = () => parseInt(require('../package.json').version, 10);
module.exports = { module.exports = {
requestRegex, requestRegex,
@ -93,7 +77,5 @@ module.exports = {
removeOptionalKeys, removeOptionalKeys,
toKebabCase, toKebabCase,
replaceInObject, replaceInObject,
moduleVersion, moduleVersion
isUrl, };
expandPath
}