Skip to content

Commit

Permalink
Merge pull request #1049 from api3dao/validator-cli
Browse files Browse the repository at this point in the history
Create validator CLI
  • Loading branch information
Siegrift authored May 4, 2022
2 parents 9fc2185 + 5f06e2b commit 07f61ea
Show file tree
Hide file tree
Showing 17 changed files with 424 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/mean-bikes-relax.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@api3/airnode-validator': minor
---

Create validator CLI
4 changes: 2 additions & 2 deletions .github/workflows/build-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -193,12 +193,12 @@ jobs:
type: ${{ job.status }}
url: ${{ secrets.SLACK_WEBHOOK_URL }}
e2e-tests:
name: E2E tests - admin and node
name: E2E tests - admin, node and validator
runs-on: ubuntu-latest
needs: build
strategy:
matrix:
package: [admin, node]
package: [admin, node, validator]
steps:
- uses: actions/cache@v2
id: restore-build
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"test:e2e-admin": "cd packages/airnode-admin && yarn run test:e2e",
"test:e2e-examples": "cd packages/airnode-examples && yarn run test:e2e",
"test:e2e-node": "cd packages/airnode-node && yarn run test:e2e",
"test:e2e-validator": "cd packages/airnode-validator && yarn run test:e2e",
"test:e2e-node:debug": "cd packages/airnode-node && yarn run test:e2e:debug",
"test:protocol": "cd packages/airnode-protocol && yarn run test",
"test:node": "(cd packages/airnode-node && yarn run test)",
Expand Down
2 changes: 1 addition & 1 deletion packages/airnode-validator/bin/validator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/usr/bin/env node

require('../src/commands/validateCmd');
require('../src/cli');
17 changes: 16 additions & 1 deletion packages/airnode-validator/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
const config = require('../../jest.config.base');

module.exports = {
...config,
projects: [
{
...config,
// Add custom settings below
name: 'e2e',
displayName: 'e2e',
testMatch: ['**/?(*.)+(feature).[tj]s?(x)'],
},
{
...config,
// Add custom settings below
displayName: 'unit',
name: 'unit',
testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[tj]s?(x)'],
},
],
};
8 changes: 6 additions & 2 deletions packages/airnode-validator/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@
"scripts": {
"build": "yarn run clean && yarn run compile && copyfiles templates/**/** dist/ && copyfiles conversions/** dist/",
"clean": "rimraf -rf ./dist *.tgz",
"cli:validator": "ts-node bin/validator.ts",
"cli": "ts-node bin/validator.ts",
"compile": "tsc -p tsconfig.build.json",
"pack": "yarn pack",
"test": "jest"
"test": "jest --selectProjects unit",
"test:e2e": "jest --selectProjects e2e",
"test:e2e:update-snapshot": "yarn test:e2e --updateSnapshot"
},
"dependencies": {
"@api3/airnode-utilities": "^0.6.0",
"@api3/promise-utils": "^0.3.0",
"dotenv": "^16.0.0",
"lodash": "^4.17.21",
"ora": "^5.4.1",
"yargs": "^17.0.1",
"zod": "^3.11.6"
},
Expand Down
64 changes: 64 additions & 0 deletions packages/airnode-validator/src/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { join } from 'path';
import * as cli from './cli';

describe('validateConfiguration', () => {
let succeedSpy: any;
let failSpy: any;

const configPath = join(__dirname, '../../test/fixtures/valid-config.json');
const secretsPath = join(__dirname, '../../test/fixtures/valid-secrets.env');

beforeEach(() => {
succeedSpy = jest.spyOn(cli, 'succeed').mockImplementation(jest.fn());
failSpy = jest.spyOn(cli, 'fail').mockImplementation(jest.fn() as any);
});

describe('calls fail', () => {
it('when config file does not exist', () => {
cli.validateConfiguration('non-existent-config.json', secretsPath);

expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Unable to read config file at "non-existent-config.json". Reason: Error: Error: ENOENT: no such file or directory'
)
);
});

it('when secrets file does not exist', () => {
cli.validateConfiguration(configPath, 'non-existent-secrets.env');

expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Unable to read secrets file at "non-existent-secrets.env". Reason: Error: Error: ENOENT: no such file or directory'
)
);
});

it('when secrets have invalid format', () => {
cli.validateConfiguration(configPath, join(__dirname, '../../test/fixtures/invalid-secrets.env'));

expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy).toHaveBeenCalledWith(
'The configuration is not valid. Reason: Error: Error interpolating secrets. Make sure the secrets format is correct'
);
});

it('when configuration is invalid', () => {
cli.validateConfiguration(configPath, join(__dirname, '../../test/fixtures/missing-secrets.env'));

expect(failSpy).toHaveBeenCalledTimes(1);
expect(failSpy).toHaveBeenCalledWith(
'The configuration is not valid. Reason: Error: Error interpolating secrets. Make sure the secrets format is correct'
);
});
});

it('calls success when for valid configuration', () => {
cli.validateConfiguration(configPath, secretsPath);

expect(succeedSpy).toHaveBeenCalledTimes(1);
expect(succeedSpy).toHaveBeenCalledWith('The configuration is valid');
});
});
66 changes: 66 additions & 0 deletions packages/airnode-validator/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import path from 'path';
import { readFileSync } from 'fs';
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { goSync } from '@api3/promise-utils';
import ora from 'ora';
import * as dotenv from 'dotenv';
import { parseConfigWithSecrets } from '../api';

export const succeed = (s: string) => ora(s).succeed();
export const fail = (s: string) => {
ora(s).fail();
process.exit(1);
};

const examples = [
'--config pathTo/config.json --secrets pathTo/secrets.env',
'-c pathTo/config.json -s pathTo/secrets.env',
];

export const validateConfiguration = (configPath: string, secretsPath: string) => {
const goRawConfig = goSync(() => readFileSync(path.resolve(configPath), 'utf-8'));
if (!goRawConfig.success) return fail(`Unable to read config file at "${configPath}". Reason: ${goRawConfig.error}`);

const goConfig = goSync(() => JSON.parse(goRawConfig.data));
if (!goConfig.success) return fail(`The configuration is not a valid JSON.`);

const goRawSecrets = goSync(() => readFileSync(path.resolve(secretsPath), 'utf-8'));
if (!goRawSecrets.success) {
return fail(`Unable to read secrets file at "${secretsPath}". Reason: ${goRawSecrets.error}`);
}

const goSecrets = goSync(() => dotenv.parse(goRawSecrets.data));
if (!goSecrets.success) return fail(`The secrets have incorrect format.`);

const parseResult = parseConfigWithSecrets(goConfig.data, goSecrets.data);
if (!parseResult.success) return fail(`The configuration is not valid. Reason: ${parseResult.error}`);

return succeed('The configuration is valid');
};

export const cli = () => {
const cliArguments = yargs(hideBin(process.argv))
.option('config', {
description: 'Path to "config.json" file to validate',
alias: 'c',
type: 'string',
demandOption: true,
})
.option('secrets', {
description: 'Path to "secrets.env" file to interpolate in the config',
alias: 's',
type: 'string',
// Making the secrets file required. If the users do not use a secrets file they can pass any empty file. However,
// not passing a secrets file is not recommended and usually is a mistake.
demandOption: true,
})
.strict()
.help()
.wrap(120)
.example(examples.map((e) => [e]))
.parseSync();

const { config, secrets } = cliArguments;
validateConfiguration(config, secrets);
};
3 changes: 3 additions & 0 deletions packages/airnode-validator/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { cli } from './cli';

cli();
28 changes: 0 additions & 28 deletions packages/airnode-validator/src/commands/validateCmd.ts

This file was deleted.

14 changes: 14 additions & 0 deletions packages/airnode-validator/test/__snapshots__/cli.feature.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`validator CLI shows help 1`] = `
"Options:
--version Show version number [boolean]
-c, --config Path to \\"config.json\\" file to validate [string] [required]
-s, --secrets Path to \\"secrets.env\\" file to interpolate in the config [string] [required]
--help Show help [boolean]
Examples:
--config pathTo/config.json --secrets pathTo/secrets.env
-c pathTo/config.json -s pathTo/secrets.env
"
`;
46 changes: 46 additions & 0 deletions packages/airnode-validator/test/cli.feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { spawnSync } from 'child_process';
import { join } from 'path';

const runValidator = (args: string[]) => {
const command = ['node', join(__dirname, '../dist/bin/validator.js'), ...args].join(' ');

return spawnSync(command, { shell: true });
};

describe('validator CLI', () => {
it('shows help', () => {
const cliHelp = runValidator(['--help']).stdout.toString();

expect(cliHelp).toMatchSnapshot();
});

it('validates valid configuration', () => {
const args = [
`--config ${join(__dirname, './fixtures/valid-config.json')}`,
`--secrets ${join(__dirname, './fixtures/valid-secrets.env')}`,
];

const output = runValidator(args);

expect(output.status).toBe(0);
// We use "expect.stringContaining" because the output begins with "✔"
expect(output.stderr.toString()).toEqual(expect.stringContaining('The configuration is valid\n'));
});

it('validates invalid configuration', () => {
const args = [
`--config ${join(__dirname, './fixtures/valid-config.json')}`,
`--secrets ${join(__dirname, './fixtures/missing-secrets.env')}`,
];

const output = runValidator(args);

expect(output.status).toBe(1);
expect(output.stderr.toString()).toEqual(
// We use "expect.stringContaining" because the output begins with "✖"
expect.stringContaining(
'The configuration is not valid. Reason: Error: Error interpolating secrets. Make sure the secrets format is correct\n'
)
);
});
});
4 changes: 4 additions & 0 deletions packages/airnode-validator/test/fixtures/invalid-secrets.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"key1": "value1",
"key2": "value2"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AIRNODE_WALLET_MNEMONIC=tube spin artefact salad slab lumber foot bitter wash reward vote cook
Loading

0 comments on commit 07f61ea

Please sign in to comment.