diff --git a/.changeset/mean-bikes-relax.md b/.changeset/mean-bikes-relax.md new file mode 100644 index 0000000000..1ef45c99e4 --- /dev/null +++ b/.changeset/mean-bikes-relax.md @@ -0,0 +1,5 @@ +--- +'@api3/airnode-validator': minor +--- + +Create validator CLI diff --git a/.github/workflows/build-test.yml b/.github/workflows/build-test.yml index bbe37290b8..cac1de9a6f 100644 --- a/.github/workflows/build-test.yml +++ b/.github/workflows/build-test.yml @@ -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 diff --git a/package.json b/package.json index 89a36f26aa..adb28a33af 100644 --- a/package.json +++ b/package.json @@ -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)", diff --git a/packages/airnode-validator/bin/validator.ts b/packages/airnode-validator/bin/validator.ts index 763275dfb1..e24f7b1e5d 100644 --- a/packages/airnode-validator/bin/validator.ts +++ b/packages/airnode-validator/bin/validator.ts @@ -1,3 +1,3 @@ #!/usr/bin/env node -require('../src/commands/validateCmd'); +require('../src/cli'); diff --git a/packages/airnode-validator/jest.config.js b/packages/airnode-validator/jest.config.js index 555c2aa9fc..3d1808a7e2 100644 --- a/packages/airnode-validator/jest.config.js +++ b/packages/airnode-validator/jest.config.js @@ -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)'], + }, + ], }; diff --git a/packages/airnode-validator/package.json b/packages/airnode-validator/package.json index 0d51dafb93..d211abe693 100644 --- a/packages/airnode-validator/package.json +++ b/packages/airnode-validator/package.json @@ -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" }, diff --git a/packages/airnode-validator/src/cli/cli.test.ts b/packages/airnode-validator/src/cli/cli.test.ts new file mode 100644 index 0000000000..d558d047bf --- /dev/null +++ b/packages/airnode-validator/src/cli/cli.test.ts @@ -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'); + }); +}); diff --git a/packages/airnode-validator/src/cli/cli.ts b/packages/airnode-validator/src/cli/cli.ts new file mode 100644 index 0000000000..5c327f73ae --- /dev/null +++ b/packages/airnode-validator/src/cli/cli.ts @@ -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); +}; diff --git a/packages/airnode-validator/src/cli/index.ts b/packages/airnode-validator/src/cli/index.ts new file mode 100644 index 0000000000..4df224f73d --- /dev/null +++ b/packages/airnode-validator/src/cli/index.ts @@ -0,0 +1,3 @@ +import { cli } from './cli'; + +cli(); diff --git a/packages/airnode-validator/src/commands/validateCmd.ts b/packages/airnode-validator/src/commands/validateCmd.ts deleted file mode 100644 index 3ae5326157..0000000000 --- a/packages/airnode-validator/src/commands/validateCmd.ts +++ /dev/null @@ -1,28 +0,0 @@ -import path from 'path'; -import { readFileSync } from 'fs'; -import yargs from 'yargs'; -import { hideBin } from 'yargs/helpers'; -import { logger } from '@api3/airnode-utilities'; -import { parseConfig } from '../api'; - -const args = yargs(hideBin(process.argv)) - .option('specification', { - description: 'Path to specification file that will be validated', - alias: ['specs', 's'], - type: 'string', - demandOption: true, - }) - .option('secrets', { - description: 'Path to .env file that will be interpolated with specification', - alias: 'i', - type: 'string', - }) - .parseSync(); - -// TODO: implement v2 validator cli -const res = (() => { - const config = JSON.parse(readFileSync(path.resolve(args.specification)).toString()); - return parseConfig(config); -})(); - -logger.log(JSON.stringify(res, null, 2)); diff --git a/packages/airnode-validator/test/__snapshots__/cli.feature.ts.snap b/packages/airnode-validator/test/__snapshots__/cli.feature.ts.snap new file mode 100644 index 0000000000..eb5970cf8b --- /dev/null +++ b/packages/airnode-validator/test/__snapshots__/cli.feature.ts.snap @@ -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 +" +`; diff --git a/packages/airnode-validator/test/cli.feature.ts b/packages/airnode-validator/test/cli.feature.ts new file mode 100644 index 0000000000..ea14d8326b --- /dev/null +++ b/packages/airnode-validator/test/cli.feature.ts @@ -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' + ) + ); + }); +}); diff --git a/packages/airnode-validator/test/fixtures/invalid-secrets.env b/packages/airnode-validator/test/fixtures/invalid-secrets.env new file mode 100644 index 0000000000..7afc33d536 --- /dev/null +++ b/packages/airnode-validator/test/fixtures/invalid-secrets.env @@ -0,0 +1,4 @@ +{ + "key1": "value1", + "key2": "value2" +} diff --git a/packages/airnode-validator/test/fixtures/missing-secrets.env b/packages/airnode-validator/test/fixtures/missing-secrets.env new file mode 100644 index 0000000000..5496ba6979 --- /dev/null +++ b/packages/airnode-validator/test/fixtures/missing-secrets.env @@ -0,0 +1 @@ +AIRNODE_WALLET_MNEMONIC=tube spin artefact salad slab lumber foot bitter wash reward vote cook diff --git a/packages/airnode-validator/test/fixtures/valid-config.json b/packages/airnode-validator/test/fixtures/valid-config.json new file mode 100644 index 0000000000..440dd681d1 --- /dev/null +++ b/packages/airnode-validator/test/fixtures/valid-config.json @@ -0,0 +1,188 @@ +{ + "chains": [ + { + "maxConcurrency": 100, + "authorizers": [], + "contracts": { + "AirnodeRrp": "0x5FbDB2315678afecb367f032d93F642f64180aa3" + }, + "id": "31337", + "providers": { + "exampleProvider": { + "url": "${PROVIDER_URL}" + } + }, + "type": "evm", + "options": { + "txType": "eip1559", + "baseFeeMultiplier": 2, + "priorityFee": { + "value": 3.12, + "unit": "gwei" + }, + "fulfillmentGasLimit": 500000 + } + } + ], + "nodeSettings": { + "cloudProvider": { + "type": "local" + }, + "airnodeWalletMnemonic": "${AIRNODE_WALLET_MNEMONIC}", + "heartbeat": { + "enabled": false + }, + "httpGateway": { + "enabled": false + }, + "httpSignedDataGateway": { + "enabled": false + }, + "logFormat": "plain", + "logLevel": "INFO", + "nodeVersion": "0.4.0", + "stage": "dev", + "skipValidation": true + }, + "triggers": { + "rrp": [ + { + "endpointId": "0xd9e8c9bcc8960df5f954c0817757d2f7f9601bd638ea2f94e890ae5481681153", + "oisTitle": "CoinGecko basic request", + "endpointName": "coinMarketData" + } + ], + "httpSignedData": [] + }, + "ois": [ + { + "oisFormat": "1.0.0", + "title": "CoinGecko basic request", + "version": "1.0.0", + "apiSpecifications": { + "servers": [ + { + "url": "https://api.coingecko.com/api/v3" + } + ], + "paths": { + "/coins/{id}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "id" + }, + { + "in": "query", + "name": "localization" + }, + { + "in": "query", + "name": "tickers" + }, + { + "in": "query", + "name": "market_data" + }, + { + "in": "query", + "name": "community_data" + }, + { + "in": "query", + "name": "developer_data" + }, + { + "in": "query", + "name": "sparkline" + } + ] + } + } + }, + "components": { + "securitySchemes": {} + }, + "security": {} + }, + "endpoints": [ + { + "name": "coinMarketData", + "operation": { + "method": "get", + "path": "/coins/{id}" + }, + "fixedOperationParameters": [ + { + "operationParameter": { + "in": "query", + "name": "localization" + }, + "value": "false" + }, + { + "operationParameter": { + "in": "query", + "name": "tickers" + }, + "value": "false" + }, + { + "operationParameter": { + "in": "query", + "name": "market_data" + }, + "value": "true" + }, + { + "operationParameter": { + "in": "query", + "name": "community_data" + }, + "value": "false" + }, + { + "operationParameter": { + "in": "query", + "name": "developer_data" + }, + "value": "false" + }, + { + "operationParameter": { + "in": "query", + "name": "sparkline" + }, + "value": "false" + } + ], + "reservedParameters": [ + { + "name": "_type", + "fixed": "int256" + }, + { + "name": "_path", + "fixed": "market_data.current_price.usd" + }, + { + "name": "_times", + "fixed": "1000000" + } + ], + "parameters": [ + { + "name": "coinId", + "operationParameter": { + "in": "path", + "name": "id" + } + } + ] + } + ] + } + ], + "apiCredentials": [] +} diff --git a/packages/airnode-validator/test/fixtures/valid-secrets.env b/packages/airnode-validator/test/fixtures/valid-secrets.env new file mode 100644 index 0000000000..31bd3e3f06 --- /dev/null +++ b/packages/airnode-validator/test/fixtures/valid-secrets.env @@ -0,0 +1,2 @@ +AIRNODE_WALLET_MNEMONIC=tube spin artefact salad slab lumber foot bitter wash reward vote cook +PROVIDER_URL=http://127.0.0.1:8545/ diff --git a/yarn.lock b/yarn.lock index d8c594cdc2..0a503dd4d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@api3/promise-utils@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@api3/promise-utils/-/promise-utils-0.3.0.tgz#e7ebf92bfd8c1d39983321fc5445070c51fce176" + integrity sha512-fH3CzEcsCQjoX6BZ5M+3yRIXZ2zz4/nFdzKUB4wvn3KjvvzvroHFZrzhbKa4mB9E4AS0xnou1AXhlrnN5Fcy+A== + "@babel/code-frame@7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"