From 530186379c98998c0f40fbeb1914d3b088f16819 Mon Sep 17 00:00:00 2001 From: Matteo Cristino <102997993+matteo-cristino@users.noreply.github.com> Date: Sat, 25 Jan 2025 20:06:19 +0100 Subject: [PATCH] feat: chains are in yaml format (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: add yaml package * feat: first introduction of yaml chains support for js functions is still missing * chore: update cspell list * feat: remove json support in favour of yaml in input steps Create some utiliy to parse the js functions, next should be to implmenet the runShellCommand function and improve typings * test: move chain from json to yaml * chore: install execa * feat: add possiblity to exec shell commands onBefore and onAfter improve also checking on getDataOrKeys that now accepts also object in input such that it will be easier for the user to use keys and data fields in yaml format * test: add test for shell commands onBefore and onAfter * refactor: move types to types.ts file * build: change to esnext in tsconfig * docs: update README.md * fix: add missing await improve also returned error from execution of js function onBefore and onAfter * test: add some failing tests * refactor: remove some old part of code * docs: fix example in the README * fix: try to support json and yaml chains ⚠️ WIP * fix: use common interface for json and yaml chain to resolve type issue problem * test: reactivate old json chain tests --- .cspell.json | 7 +- README.md | 122 +++++++++------ package.json | 4 +- pnpm-lock.yaml | 26 ++-- src/lib/chain.spec.ts | 349 ++++++++++++++++++++++++++++++++++++++++-- src/lib/chain.ts | 182 ++++++++++------------ src/lib/jsonChain.ts | 71 +++++++++ src/lib/types.ts | 170 ++++++++++++++++++++ src/lib/yamlChain.ts | 87 +++++++++++ tsconfig.json | 6 +- 10 files changed, 849 insertions(+), 175 deletions(-) create mode 100644 src/lib/jsonChain.ts create mode 100644 src/lib/types.ts create mode 100644 src/lib/yamlChain.ts diff --git a/.cspell.json b/.cspell.json index d20f6df..5ec1f02 100644 --- a/.cspell.json +++ b/.cspell.json @@ -39,7 +39,10 @@ "cristino", "rngseed", "qrcode", - "pocketbase" + "pocketbase", + "hola", + "mundo", + "slangroomfs" ], "flagWords": [], "ignorePaths": [ @@ -49,4 +52,4 @@ "tsconfig.json", "node_modules/**" ] -} +} \ No newline at end of file diff --git a/README.md b/README.md index 910c8d1..ad66e58 100644 --- a/README.md +++ b/README.md @@ -37,14 +37,15 @@ Zenroom and zencode are part of the [DECODE project](https://decodeproject.eu) a
🚩 Table of Contents (click to expand) -- [Install](#-install) -- [Quick start](#-quick-start) -- [Testing](#-testing) -- [Troubleshooting & debugging](#-troubleshooting--debugging) -- [Acknowledgements](#-acknowledgements) -- [Links](#-links) -- [Contributing](#-contributing) -- [License](#-license) +- [💾 Install](#-install) +- [🎮 Quick start](#-quick-start) + - [Step definitions](#step-definitions) +- [📋 Testing](#-testing) +- [🐛 Troubleshooting \& debugging](#-troubleshooting--debugging) +- [😍 Acknowledgements](#-acknowledgements) +- [🌐 Links](#-links) +- [👤 Contributing](#-contributing) +- [💼 License](#-license)
--- @@ -53,6 +54,8 @@ Zenroom and zencode are part of the [DECODE project](https://decodeproject.eu) a `pnpm add @dyne/slangroom-chain` +**[🔝 back to top](#toc)** + --- ## 🎮 Quick start @@ -70,35 +73,34 @@ import { execute } from '@dyne/slangroom-chain'; const newAccount = `{"username": "Alice"}`; -const steps_definition = { - verbosity: false, - steps: [ - { - id: 'step1', - slangroom: `Scenario ecdh: create the keypair at user creation -Given that my name is in a 'string' named 'username' -When I create the keypair -Then print my 'keypair'`, - data: newAccount, - }, - { - id: 'step2', - slangroom: `Scenario 'ecdh': Publish the public key -Given that my name is in a 'string' named 'username' -and I have my 'keypair' -Then print my 'public key' from 'keypair'`, - data: newAccount, - keysFromStep: 'step1', - }, - ], -}; - -execute(steps).then((r) => console.log(r)); +const steps_definition = ` + verbose: false + steps: + - id: 'step1' + zencode: | + Scenario ecdh: create the keyring at user creation + Given that my name is in a 'string' named 'username' + When I create the ecdh key + Then print my 'keyring' + data: ${newAccount} + - id: 'step2' + zencode: | + Scenario 'ecdh': Create and publish public key + Given that my name is in a 'string' named 'username' + and I have my 'keyring' + When I create the ecdh public key + Then print my 'ecdh public key' + data: ${newAccount} + keysFromStep: 'step1'`; + +execute(steps_definition).then((r) => console.log(r)); ``` ### Step definitions -The steps definition is an object literal defined as follows: +As can be seen the `steps_definition` is written following the +[yaml format](https://yaml.org/spec/1.2.2/). Internally these steps are +converted into a json format that is typed as follow: ```typescript type Steps = { @@ -111,39 +113,69 @@ type Steps = { The single step definition is an object literal defined as follows: ```typescript -type Step = { +type BasicStep = { readonly id: string; - readonly slangroom: string; readonly data?: string; readonly dataFromStep?: string; - readonly dataTransform?: - | ((data: string) => string) - | ((data: string) => Promise); + readonly dataFromFile?: string; + readonly dataTransform?: string; readonly keys?: string; readonly keysFromStep?: string; - readonly keysTransform?: - | ((data: string) => string) - | ((data: string) => Promise); + readonly keysFromFile?: string; + readonly keysTransform?: string; readonly conf?: string; + readonly onAfter?: { + readonly run?: string; + readonly jsFunction?: string; + }; + readonly onBefore?: { + readonly run?: string; + readonly jsFunction?: string; + }; }; + +type Step = + | (BasicStep & { + readonly zencode: string; + }) + | (BasicStep & { + readonly zencodeFromFile: string; + }); ``` The list of the attributes are: - **id** mandatory, a unique string to identify your step -- **slangroom** mandatory, your slangroom to run +- **zencode** must be present if *zencodeFromFile* is not present, your slangroom to run +- **zencodeFromFile** must be present if *zencode* is not present, the path to your slangroom contract to run - **data** optional, the data; when you want to pass it directly - **dataFromStep** optional, the step.id to get the result as input -- **dataTransform** optional, a function that accepts a string and return a string, +- **dataFromFile** optional, the path to the data file +- **dataTransform** optional, a body of a js function that has `data` as input string and return a string, that will be executed on data just before the execution. This intended to be used to mangle your data with some transformation (eg. remove a key, or rename it) - **keys** optional, the keys; when you want to pass it directly - **keysFromStep** optional, the step.id to get the result as input -- **keysTransform** optional, a function that accepts a string and return a string, +- **keysFromFile** optional, the path to the keys file +- **keysTransform** optional, a body of a js function that has `keys` as input string and return a string, that will be executed on keys just before the execution. This intended to be used to mangle your keys with some transformation (eg. remove an attribute, or rename it) -- **conf** optional, the zenroom conf for the specific slangroom_exec (eg. 'memmanager=lw') +- **conf** optional, the zenroom conf for the specific slangroom_exec (eg. 'rngseed=hex:...') overrides generic one +- **onBefore** optional, can contains **jsFunction** or **run** attributes where: + - **jsFunction** optional, the body of a js function that has `zencode, data, keys, conf` as input strings and return a nothing + - **run** optional, a shell command to run + + This will be executed before the contract execution, it can not modify anything, but can be used to + perform external operations (eg. do a call to an external API, send a email, etc) +- **onAfter** optional, can contains **jsFunction** or **run** attributes where: + - **jsFunction** optional, the body of a js function that has `result, zencode, data, keys, conf` as input strings and return a nothing + - **run** optional, a shell command to run + + This will be executed after the contract execution, it can not modify anything, but can be used to + perform external operations (eg. do a call to an external API, send a email, etc) + +**[🔝 back to top](#toc)** --- diff --git a/package.json b/package.json index b2f3588..c75fa4c 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,8 @@ "@slangroom/shell": "^1.33.14", "@slangroom/timestamp": "^1.33.14", "@slangroom/wallet": "^1.33.14", - "@slangroom/zencode": "^1.33.14" + "@slangroom/zencode": "^1.33.14", + "execa": "^9.3.0", + "yaml": "^2.4.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 275e430..2c1badf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,12 @@ importers: '@slangroom/zencode': specifier: ^1.33.14 version: 1.33.14(zenroom@4.31.2) + execa: + specifier: ^9.3.0 + version: 9.3.0 + yaml: + specifier: ^2.4.5 + version: 2.4.5 devDependencies: '@ava/typescript': specifier: ^5.0.0 @@ -1878,9 +1884,9 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} - execa@9.1.0: - resolution: {integrity: sha512-lSgHc4Elo2m6bUDhc3Hl/VxvUDJdQWI40RZ4KMY9bKRc+hgMOT7II/JjbNDhI8VnMtrCb7U/fhpJIkLORZozWw==} - engines: {node: '>=18'} + execa@9.3.0: + resolution: {integrity: sha512-l6JFbqnHEadBoVAVpN5dl2yCyfX28WoBAGaoQcNmLLSedOxTxcn2Qa83s8I/PA5i56vWru2OHOtrwF7Om2vqlg==} + engines: {node: ^18.19.0 || >=20.5.0} expand-template@2.0.3: resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} @@ -4404,8 +4410,8 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.4.2: - resolution: {integrity: sha512-B3VqDZ+JAg1nZpaEmWtTXUlBneoGx6CPM9b0TENK6aoSu5t73dItudwdgmi6tHlIZZId4dZ9skcAQ2UbcyAeVA==} + yaml@2.4.5: + resolution: {integrity: sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==} engines: {node: '>= 14'} hasBin: true @@ -5109,7 +5115,7 @@ snapshots: dependencies: '@semantic-release/error': 4.0.0 aggregate-error: 5.0.0 - execa: 9.1.0 + execa: 9.3.0 fs-extra: 11.2.0 lodash-es: 4.17.21 nerf-dart: 1.0.0 @@ -6165,7 +6171,7 @@ snapshots: dependencies: '@cspell/cspell-types': 8.8.1 comment-json: 4.2.3 - yaml: 2.4.2 + yaml: 2.4.5 cspell-dictionary@8.8.1: dependencies: @@ -6671,7 +6677,7 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 - execa@9.1.0: + execa@9.3.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 cross-spawn: 7.0.3 @@ -8495,7 +8501,7 @@ snapshots: cosmiconfig: 9.0.0(typescript@5.4.5) debug: 4.3.4 env-ci: 11.0.0 - execa: 9.1.0 + execa: 9.3.0 figures: 6.1.0 find-versions: 6.0.0 get-stream: 6.0.1 @@ -9255,7 +9261,7 @@ snapshots: yallist@4.0.0: {} - yaml@2.4.2: {} + yaml@2.4.5: {} yargs-parser@18.1.3: dependencies: diff --git a/src/lib/chain.spec.ts b/src/lib/chain.spec.ts index a80a411..f22a890 100644 --- a/src/lib/chain.spec.ts +++ b/src/lib/chain.spec.ts @@ -2,7 +2,166 @@ import test from 'ava'; import { execute } from './chain.js'; -test('should execute work', async (t) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).variable = 'Hello'; + +test('should execute work [yaml]', async (t) => { + const account = JSON.stringify({ username: 'Alice' }); + const participants = JSON.stringify({ + participants: ['jaromil@dyne.org', 'puria@dyne.org', 'andrea@dyne.org'], + }); + const participant_email = JSON.stringify({ + email: 'bob@wonder.land', + petition_uid: 'More privacy for all!', + }); + const steps = ` + steps: + - id: 'issuer_keyring' + zencode: | + Scenario credential: publish verifier + Given that I am known as 'Decidiamo' + When I create the issuer key + and I create the issuer public key + Then print my 'issuer_public_key' + Then print my 'keyring' + - id: 'keyring' + zencode: | + Scenario ecdh: create the key at user creation + Given that my name is in a 'string' named 'username' + When I create the ecdh key + Then print my 'keyring' + data: '${account}' + - id: 'pubkey' + zencode: | + Scenario 'ecdh': Publish the public key + Given that my name is in a 'string' named 'username' + and I have my 'keyring' + When I create the ecdh public key + Then print my 'ecdh_public_key' + data: '${account}' + keysFromStep: 'keyring' + - id: 'petition_request' + zencode: | + Scenario credential: create the petition credentials + Scenario petition: create the petition + Scenario ecdh: sign the petition + + # state my identity + Given that I am known as 'Alice' + and I have my 'keyring' + and I have a 'string array' named 'participants' + + # create the petition and its keypair + When I create the credential key + and I create the petition 'More privacy for all!' + + # sign the hash + # When I create the hash of 'petition' + When I create the ecdh signature of 'petition' + and I rename 'ecdh_signature' to 'petition.signature' + + When I create the ecdh signature of 'participants' + and I rename the 'ecdh signature' to 'participants.signature' + + Then print my 'keyring' + and print the 'petition' + and print the 'petition.signature' + and print the 'participants' + and print the 'participants.signature' + keysFromStep: 'keyring' + data: '${participants}' + - id: 'new_petition' + dataFromStep: 'petition_request' + dataTransform: | + const o = JSON.parse(data); + delete o.Alice; + return JSON.stringify(o); + keysFromStep: 'pubkey' + zencode: | + Scenario ecdh + Scenario petition + + Given that I have a 'ecdh public key' from 'Alice' + and I have a 'petition' + and I have a 'ecdh signature' named 'petition.signature' + and I have a 'string array' named 'participants' + and I have a 'ecdh signature' named 'participants.signature' + + When I verify the 'petition' has a ecdh signature in 'petition.signature' by 'Alice' + and I verify the new petition to be empty + + When I verify the 'participants' has a ecdh signature in 'participants.signature' by 'Alice' + and I verify 'participants' contains a list of emails + + When I pickup from path 'petition.uid' + + Then print 'petition' + and print 'participants' + and print the 'uid' as 'string' + - id: 'petition' + dataFromStep: 'new_petition' + keysFromStep: 'issuer_keyring' + zencode: | + Scenario credential + Scenario petition + Given I am 'Decidiamo' + and I have my 'keyring' + and I have a 'petition' + When I create the issuer public key + Then print the 'issuer public key' + Then print the 'petition' + - id: 'signature_credential' + keysFromStep: 'issuer_keyring' + data: '${participant_email}' + zencode: | + Scenario credential + Given that I am known as 'Decidiamo' + and I have my 'keyring' + and I have a 'string' named 'email' + and I have a 'string' named 'petition_uid' + When I create the issuer public key + When I append 'email' to 'petition_uid' + and I create the hash of 'petition_uid' + and I create the credential key with secret key 'hash' + and I create the credential request + and I create the credential signature + and I create the credentials + Then print the 'credentials' + and print the 'keyring' + and print the 'verifier' + - id: 'sign_proof' + dataFromStep: 'signature_credential' + zencode: | + Scenario credential + Scenario petition: sign petition + Given I am 'Bob' + and I have a 'keyring' + and I have a 'credentials' + and I have a 'base64 dictionary' named 'verifier' + When I create the issuer public key + When I aggregate the verifiers in 'verifier' + and I create the petition signature 'More privacy for all!' + Then print the 'petition signature' + - id: 'sign_petition' + dataFromStep: 'petition' + keysFromStep: 'sign_proof' + zencode: | + Scenario credential + Scenario petition: aggregate signature + Given that I am 'Decidiamo' + and I have a 'petition signature' + and I have a 'petition' + When I verify the petition signature is not a duplicate + and I verify the petition signature is just one more + and I add the signature to the petition + Then print the 'petition' + `; + const result = JSON.parse(await execute(steps)); + //t.log(result); + t.is(result.petition.list.length, 1); +}); + +test('should execute work [json]', async (t) => { const account = JSON.stringify({ username: 'Alice' }); const participants = JSON.stringify({ participants: ['jaromil@dyne.org', 'puria@dyne.org', 'andrea@dyne.org'], @@ -46,24 +205,19 @@ test('should execute work', async (t) => { zencode: `Scenario credential: create the petition credentials Scenario petition: create the petition Scenario ecdh: sign the petition - # state my identity Given that I am known as 'Alice' and I have my 'keyring' and I have a 'string array' named 'participants' - # create the petition and its keypair When I create the credential key and I create the petition 'More privacy for all!' - # sign the hash # When I create the hash of 'petition' When I create the ecdh signature of 'petition' and I rename 'ecdh_signature' to 'petition.signature' - When I create the ecdh signature of 'participants' and I rename the 'ecdh signature' to 'participants.signature' - Then print my 'keyring' and print the 'petition' and print the 'petition.signature' @@ -84,21 +238,16 @@ and print the 'participants.signature' keysFromStep: 'pubkey', zencode: `Scenario ecdh Scenario petition - Given that I have a 'ecdh public key' from 'Alice' and I have a 'petition' and I have a 'ecdh signature' named 'petition.signature' and I have a 'string array' named 'participants' and I have a 'ecdh signature' named 'participants.signature' - When I verify the 'petition' has a ecdh signature in 'petition.signature' by 'Alice' and I verify the new petition to be empty - When I verify the 'participants' has a ecdh signature in 'participants.signature' by 'Alice' and I verify 'participants' contains a list of emails - When I pickup from path 'petition.uid' - Then print 'petition' and print 'participants' and print the 'uid' as 'string'`, @@ -168,11 +317,35 @@ and print the 'participants.signature' }; const result = JSON.parse(await execute(steps)); - t.log(result); t.is(result.petition.list.length, 1); }); -test('specific conf step should override the generic one', async (t) => { +test('specific conf step should override the generic one [yaml]', async (t) => { + const logs: string[] = []; + console.log = (x) => { + logs.push(x); + }; + const steps = ` + conf: 'rngseed=hex:74eeeab870a394175fae808dd5dd3b047f3ee2d6a8d01e14bff94271565625e98a63babe8dd6cbea6fedf3e19de4bc80314b861599522e44409fdd20f7cd6cfc' + verbose: true + steps: + - id: 'some' + conf: 'rngseed=hex:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111' + zencode: | + Given that I have a 'string' named 'hello' + Then print all data as 'string' + data: '${JSON.stringify({ hello: 'world' })}'`; + await execute(steps); + t.true( + logs + .join() + .includes( + 'CONF: rngseed=hex:11111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111', + ), + ); +}); + +test('specific conf step should override the generic one [json]', async (t) => { const logs: string[] = []; console.log = (x) => { logs.push(x); @@ -200,7 +373,22 @@ test('specific conf step should override the generic one', async (t) => { ); }); -test('keyTransform should work', async (t) => { +test('keyTransform should work [yaml]', async (t) => { + const steps = ` + steps: + - id: 'some' + conf: 'debug=0' + zencode: | + Given that I have a 'string' named 'hello' + Then print all data as 'string' + keys: '${JSON.stringify({ hello: 'world' })}' + keysTransform: | + return JSON.stringify(JSON.parse(keys))`; + const result = await execute(steps); + t.deepEqual(JSON.parse(result), { hello: 'world' }); +}); + +test('keyTransform should work [json]', async (t) => { const steps = { verbose: true, steps: [ @@ -220,7 +408,7 @@ test('keyTransform should work', async (t) => { t.deepEqual(JSON.parse(result), { hello: 'world' }); }); -test('callbacks should work', async (t) => { +test('callbacks should work [json]', async (t) => { let before = false; let after = false; let afterResult = ''; @@ -254,7 +442,18 @@ test('callbacks should work', async (t) => { t.deepEqual(JSON.parse(result), { hello: 'world' }); }); -test('read from file', async (t) => { +test('read from file [yaml]', async (t) => { + const steps = ` + steps: + - id: 'from file' + zencodeFromFile: 'test_contracts/hello.zen' + dataFromFile: 'test_contracts/hello.data.json' + keysFromFile: 'test_contracts/hello.keys.json'`; + const result = await execute(steps); + t.deepEqual(JSON.parse(result), { hello: 'world', bonjour: 'monde' }); +}); + +test('read from file [json]', async (t) => { process.env['FILES_DIR'] = '.'; const steps = { steps: [ @@ -270,7 +469,17 @@ test('read from file', async (t) => { t.deepEqual(JSON.parse(result), { hello: 'world', bonjour: 'monde' }); }); -test('read input data', async (t) => { +test('read input data [yaml]', async (t) => { + const steps = ` + steps: + - id: 'from file' + zencodeFromFile: 'test_contracts/hello.zen' + keysFromFile: 'test_contracts/hello.keys.json'`; + const result = await execute(steps, '{"hello": "world"}'); + t.deepEqual(JSON.parse(result), { hello: 'world', bonjour: 'monde' }); +}); + +test('read input data [json]', async (t) => { const steps = { steps: [ { @@ -283,3 +492,109 @@ test('read input data', async (t) => { const result = await execute(steps, '{"hello": "world"}'); t.deepEqual(JSON.parse(result), { hello: 'world', bonjour: 'monde' }); }); + +test('mix zencode and zencodeFromFile', async (t) => { + const steps = ` + steps: + - id: hello from file + zencodeFromFile: test_contracts/hello.zen + keysFromFile: test_contracts/hello.keys.json + - id: add another hello + dataFromStep: hello from file + zencode: | + Given I have a 'string' named 'hello' + Given I have a 'string' named 'bonjour' + + When I set 'hola' to 'mundo' as 'string' + + Then print the 'hello' + Then print the 'bonjour' + Then print the 'hola'`; + const result = await execute(steps, '{"hello": "world"}'); + t.deepEqual(JSON.parse(result), { + hello: 'world', + bonjour: 'monde', + hola: 'mundo', + }); +}); + +test('onBefore create a file and delete it onAfter', async (t) => { + process.env['FILES_DIR'] = '.'; + const steps = ` + steps: + - id: create and delete new_file + onBefore: + run: | + touch new_file.test + zencode: | + Rule unknown ignore + Given I send path 'file_path' and verify file exists + Given I have a 'string' named 'file_path' + Then print the 'file_path' + keys: + file_path: new_file.test + onAfter: + run: | + rm new_file.test + - id: check new_file does not exist + dataFromStep: create and delete new_file + zencode: | + Rule unknown ignore + Given I send path 'file_path' and verify file does not exist + Given I have a 'string' named 'file_path' + Then print the string 'everything works'`; + const result = await execute(steps); + t.deepEqual(JSON.parse(result), { + output: ['everything_works'], + }); +}); + +// failing tests + +test('check for variables onBefore and onAfter, pass onBefore and fails onAfter', async (t) => { + const steps = ` + steps: + - id: create and delete new_file + onBefore: + jsFunction: | + if(variable !== 'Hello') { + throw new Error('variable is not Hello: '+variable) + } + zencode: | + Given nothing + When I set 'res' to 'cat' as 'string' + Then print the 'res' + onAfter: + jsFunction: | + const r = variable+'_'+JSON.parse(result).res + if(r !== 'Hello_world') { + throw new Error('result is not Hello_world: '+r) + }`; + const fn = execute(steps); + const err = await t.throwsAsync(fn); + t.is( + err?.message, + `Error executing JS function: +const r = variable+'_'+JSON.parse(result).res +if(r !== 'Hello_world') { + throw new Error('result is not Hello_world: '+r) +} + +Error: result is not Hello_world: Hello_cat`, + ); +}); + +test('invalid data', async (t) => { + const steps = ` + steps: + - id: step with invalid data + zencode: | + Rule unknown ignore + Given I send path 'file_path' and verify file exists + Given I have a 'string' named 'file_path' + Then print the 'file_path' + data: 1`; + const fn = execute(steps); + const err = await t.throwsAsync(fn); + t.is(err?.message, 'No valid data provided for step step with invalid data'); +}); diff --git a/src/lib/chain.ts b/src/lib/chain.ts index 25c1b34..59dbcac 100644 --- a/src/lib/chain.ts +++ b/src/lib/chain.ts @@ -16,6 +16,10 @@ import { timestamp } from '@slangroom/timestamp'; import { wallet } from '@slangroom/wallet'; import { zencode } from '@slangroom/zencode'; +import { JsonChain } from './jsonChain.js'; +import type { Chain, JsonSteps, Results, Step } from './types'; +import { YamlChain } from './yamlChain.js'; + const slang = new Slangroom( db, slangroomfs, @@ -37,123 +41,107 @@ const readFromFile = (path: string): string => { return fs.readFileSync(path).toString('utf-8'); }; -type Step = { - readonly id: string; - readonly zencode?: string; - readonly zencodeFromFile?: string; - readonly data?: string; - readonly dataFromStep?: string; - readonly dataFromFile?: string; - readonly dataTransform?: - | ((data: string) => string) - | ((data: string) => Promise); - readonly keys?: string; - readonly keysFromStep?: string; - readonly keysFromFile?: string; - readonly keysTransform?: - | ((keys: string) => string) - | ((keys: string) => Promise); - readonly conf?: string; - readonly onAfter?: - | (( - result: string, - zencode: string, - data: string | undefined, - keys: string | undefined, - conf: string | undefined, - ) => void) - | (( - result: string, - zencode: string, - data: string | undefined, - keys: string | undefined, - conf: string | undefined, - ) => Promise); - readonly onBefore?: - | (( - zencode: string, - data: string | undefined, - keys: string | undefined, - conf: string | undefined, - ) => void) - | (( - zencode: string, - data: string | undefined, - keys: string | undefined, - conf: string | undefined, - ) => Promise); +const verbose = (verbose: boolean | undefined): ((m: string) => void) => { + if (verbose) return (message: string) => console.log(message); + return () => {}; }; -type Steps = { - readonly steps: readonly Step[]; - readonly conf?: string; - readonly verbose?: boolean; +const getDataOrKeys = ( + step: Step, + results: Results, + dataOrKeys: 'data' | 'keys', +): string => { + const fromFile: keyof Step = `${dataOrKeys}FromFile`; + const fromStep: keyof Step = `${dataOrKeys}FromStep`; + if (!step[fromFile] && !step[fromStep] && !step[dataOrKeys]) return '{}'; + let data; + if (step[fromFile] && typeof step[fromFile] === 'string') + data = readFromFile(step[fromFile] as string); + else if (step[fromStep] && typeof step[fromStep] === 'string') + data = results[step[fromStep] as string]; + else if (typeof step[dataOrKeys] === 'string') data = step[dataOrKeys]; + else if (typeof step[dataOrKeys] === 'object') + data = JSON.stringify(step[dataOrKeys]); + if (!data) + throw new Error(`No valid ${dataOrKeys} provided for step ${step.id}`); + return data; }; -type Results = { - [x: string]: string; +const getDataAndKeys = ( + step: Step, + results: Results, +): { data: string; keys: string } => { + return { + data: getDataOrKeys(step, results, 'data'), + keys: getDataOrKeys(step, results, 'keys'), + }; }; export const execute = async ( - steps: Steps, + steps: string | JsonSteps, inputData?: string, ): Promise => { const results: Results = {}; let final = ''; let firstIteration = true; - for await (const step of steps.steps) { - let data = step.dataFromFile - ? readFromFile(step.dataFromFile) - : step.dataFromStep - ? results[step.dataFromStep] - : step.data; + let parsedSteps: Chain; + if (typeof steps === 'string') parsedSteps = new YamlChain(steps); + else parsedSteps = new JsonChain(steps); + const verboseFn = verbose(parsedSteps.steps.verbose); + for (const step of parsedSteps.steps.steps) { + let { data, keys } = getDataAndKeys(step, results); + // TODO: remove firstIteration boolean variable and + // each time the data is input take as data the result of + // previous step for easier chaining if (firstIteration) { - if (typeof data == 'undefined') data = inputData; + if (data === '{}' && inputData) data = inputData; firstIteration = false; } - let keys = step.keysFromFile - ? readFromFile(step.keysFromFile) - : step.keysFromStep - ? results[step.keysFromStep] - : step.keys; - const conf = step.conf ? step.conf : steps.conf; - const zencode = step.zencodeFromFile - ? readFromFile(step.zencodeFromFile) - : step.zencode || ''; - if (steps.verbose) { - console.log(`Executing contract ${step.id} `); - console.log(`ZENCODE: ${zencode}`); - console.log(`DATA: ${data}`); - console.log(`KEYS: ${keys}`); - console.log(`CONF: ${conf}`); - } - if (data && step.dataTransform) { - data = await step.dataTransform(data); - if (steps.verbose) { - console.log(`TRANSFORMED DATA: ${data}`); - } - } - if (keys && step.keysTransform) { - keys = await step.keysTransform(keys); - if (steps.verbose) { - console.log(`TRANSFORMED KEYS: ${keys}`); - } - } - if (step.onBefore) await step.onBefore(zencode, data, keys, conf); + const conf = step.conf ? step.conf : parsedSteps.steps.conf; + const zencode = + 'zencodeFromFile' in step + ? readFromFile(step.zencodeFromFile) + : step.zencode; + verboseFn( + `Executing contract ${step.id}\nZENCODE: ${zencode}\nDATA: ${data}\nKEYS: ${keys}\nCONF: ${conf}`, + ); + data = await parsedSteps.manageTransform( + step.dataTransform, + data, + 'data', + verboseFn, + ); + keys = await parsedSteps.manageTransform( + step.keysTransform, + keys, + 'keys', + verboseFn, + ); + await parsedSteps.manageBefore(step.onBefore, zencode, data, keys, conf); const { result, logs } = await slang.execute(zencode, { - data: data ? JSON.parse(data) : {}, - keys: keys ? JSON.parse(keys) : {}, + data: JSON.parse(data), + keys: JSON.parse(keys), conf, }); - if (step.onAfter) - await step.onAfter(JSON.stringify(result), zencode, data, keys, conf); - results[step.id] = JSON.stringify(result); - if (steps.verbose) { - console.log(logs); + let stringResult; + try { + stringResult = JSON.stringify(result); + } /* c8 ignore next 4 */ catch (e) { + // this should be unreachable + throw new Error(`failed to stringify result: ${result}\ngot error: ${e}`); } - final = JSON.stringify(result); + await parsedSteps.manageAfter( + step.onAfter, + stringResult, + zencode, + data, + keys, + conf, + ); + results[step.id] = stringResult; + verboseFn(logs); + final = stringResult; } return final; }; - diff --git a/src/lib/jsonChain.ts b/src/lib/jsonChain.ts new file mode 100644 index 0000000..78217c0 --- /dev/null +++ b/src/lib/jsonChain.ts @@ -0,0 +1,71 @@ +import { execaCommand } from 'execa'; + +import type { + Chain, + JsonOnAfter, + JsonOnBefore, + JsonSteps, + JsonTransformFn, +} from './types'; + +const execShellCommand = async (command: string): Promise => { + await execaCommand(command); +}; + +export class JsonChain implements Chain { + steps: JsonSteps; + constructor(steps: JsonSteps) { + this.steps = steps; + } + + async manageTransform( + transformFn: JsonTransformFn | undefined, + transformData: string, + transformType: 'data' | 'keys', + verboseFn: (m: string) => void, + ): Promise { + if (!transformFn) return transformData; + const data = await transformFn(transformData); + verboseFn(`TRANSFORMED ${transformType}: ${data}`); + return data; + } + + async manageBefore( + stepOnBefore: JsonOnBefore | undefined, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise { + if (!stepOnBefore) return; + if (typeof stepOnBefore === 'function') { + await stepOnBefore(zencode, data, keys, conf); + } else if (typeof stepOnBefore === 'object') { + if (stepOnBefore.jsFunction) { + await stepOnBefore.jsFunction(zencode, data, keys, conf); + } else if (stepOnBefore.run) { + await execShellCommand(stepOnBefore.run); + } + } + } + + async manageAfter( + stepOnAfter: JsonOnAfter | undefined, + result: string, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise { + if (!stepOnAfter) return; + if (typeof stepOnAfter === 'function') { + await stepOnAfter(result, zencode, data, keys, conf); + } else if (typeof stepOnAfter === 'object') { + if (stepOnAfter.jsFunction) { + await stepOnAfter.jsFunction(result, zencode, data, keys, conf); + } else if (stepOnAfter.run) { + await execShellCommand(stepOnAfter.run); + } + } + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..5dea741 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,170 @@ +// types for json format +type JsonOnBeforeFn = + | (( + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ) => void) + | (( + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ) => Promise); + +export type JsonOnBefore = + | JsonOnBeforeFn + | { + jsFunction?: JsonOnBeforeFn; + run?: string; + }; + +type JsonOnAfterFn = + | (( + result: string, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ) => void) + | (( + result: string, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ) => Promise); + +export type JsonOnAfter = + | JsonOnAfterFn + | { + jsFunction?: JsonOnAfterFn; + run?: string; + }; + +export type JsonOnBeforeOrAfter = JsonOnBefore | JsonOnAfter; + +type JsonDataTransform = + | ((data: string) => string) + | ((data: string) => Promise); + +type JsonKeysTransform = + | ((keys: string) => string) + | ((keys: string) => Promise); + +export type JsonTransformFn = JsonDataTransform | JsonKeysTransform; + +type JsonBasicStep = { + readonly id: string; + readonly data?: string; + readonly dataFromStep?: string; + readonly dataFromFile?: string; + readonly dataTransform?: JsonDataTransform; + readonly keys?: string; + readonly keysFromStep?: string; + readonly keysFromFile?: string; + readonly keysTransform?: JsonKeysTransform; + readonly conf?: string; + readonly onAfter?: JsonOnAfter; + readonly onBefore?: JsonOnBefore; +}; + +export type JsonStep = + | (JsonBasicStep & { + readonly zencode: string; + }) + | (JsonBasicStep & { + readonly zencodeFromFile: string; + }); + +export type JsonSteps = { + readonly steps: readonly JsonStep[]; + readonly conf?: string; + readonly verbose?: boolean; +}; + +// types for yaml format +export type YamlOnBeforeOrAfter = { + readonly run?: string; + readonly jsFunction?: string; +}; + +type YamlBasicStep = { + readonly id: string; + readonly data?: string; + readonly dataFromStep?: string; + readonly dataFromFile?: string; + readonly dataTransform?: string; + readonly keys?: string; + readonly keysFromStep?: string; + readonly keysFromFile?: string; + readonly keysTransform?: string; + readonly conf?: string; + readonly onAfter?: YamlOnBeforeOrAfter; + readonly onBefore?: YamlOnBeforeOrAfter; +}; + +type YamlStep = + | (YamlBasicStep & { + readonly zencode: string; + }) + | (YamlBasicStep & { + readonly zencodeFromFile: string; + }); + +export type YamlSteps = { + readonly steps: readonly YamlStep[]; + readonly conf?: string; + readonly verbose?: boolean; +}; + +// types for both +export type Step = YamlStep | JsonStep; +export type Steps = YamlSteps | JsonSteps; + +export type Results = { + [x: string]: string; +}; + +type OnBeforeData = { + readonly zencode: string; + readonly data?: string; + readonly keys?: string; + readonly conf?: string; +}; + +type OnAfterData = { + readonly result: string; + readonly zencode: string; + readonly data?: string; + readonly keys?: string; + readonly conf?: string; +}; + +export type OnBeforeOrAfterData = OnBeforeData | OnAfterData; + +export interface Chain { + steps: JsonSteps | YamlSteps; + manageTransform( + transformFn: string | JsonTransformFn | undefined, + transformData: string, + transformType: 'data' | 'keys', + verboseFn: (m: string) => void, + ): Promise; + manageBefore( + stepOnBefore: YamlOnBeforeOrAfter | JsonOnBefore | undefined, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise; + manageAfter( + stepOnAfter: YamlOnBeforeOrAfter | JsonOnAfter | undefined, + result: string, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise; +} diff --git a/src/lib/yamlChain.ts b/src/lib/yamlChain.ts new file mode 100644 index 0000000..f97424b --- /dev/null +++ b/src/lib/yamlChain.ts @@ -0,0 +1,87 @@ +import { execaCommand } from 'execa'; +import YAML from 'yaml'; + +import type { + Chain, + OnBeforeOrAfterData, + YamlOnBeforeOrAfter, + YamlSteps, +} from './types'; + +/* c8 ignore next */ +const AsyncFunction = async function () {}.constructor; + +const execJsFun = async ( + stringFn: string, + args: Record, +): Promise => { + const fn = AsyncFunction(...Object.keys(args), stringFn); + try { + return await fn(...Object.values(args)); + } catch (e) { + throw new Error(`Error executing JS function:\n${stringFn}\n${e}`); + } +}; + +const execShellCommand = async (command: string): Promise => { + await execaCommand(command); +}; + +const manageBeforeOrAfter = async ( + stepOnBeforeOrAfter: YamlOnBeforeOrAfter, + data: OnBeforeOrAfterData, +): Promise => { + if (stepOnBeforeOrAfter.jsFunction) + await execJsFun(stepOnBeforeOrAfter.jsFunction, data); + if (stepOnBeforeOrAfter.run) await execShellCommand(stepOnBeforeOrAfter.run); +}; + +export class YamlChain implements Chain { + steps: YamlSteps; + constructor(steps: string) { + this.steps = YAML.parse(steps); + } + + async manageTransform( + transformFn: string | undefined, + transformData: string, + transformType: 'data' | 'keys', + verboseFn: (m: string) => void, + ): Promise { + if (!transformFn) return transformData; + const data = await execJsFun(transformFn, { + [transformType]: transformData, + }); + verboseFn(`TRANSFORMED ${transformType}: ${data}`); + return data; + } + + async manageBefore( + stepOnBefore: YamlOnBeforeOrAfter | undefined, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise { + if (!stepOnBefore) return; + await manageBeforeOrAfter(stepOnBefore, { zencode, data, keys, conf }); + } + + async manageAfter( + stepOnAfter: YamlOnBeforeOrAfter | undefined, + result: string, + zencode: string, + data: string | undefined, + keys: string | undefined, + conf: string | undefined, + ): Promise { + if (!stepOnAfter) return; + await manageBeforeOrAfter(stepOnAfter, { + result, + zencode, + data, + keys, + conf, + }); + } +} diff --git a/tsconfig.json b/tsconfig.json index 4dfb1e6..5c8a4e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,11 @@ { "compilerOptions": { "incremental": true, - "target": "es2016", + "target": "ESNext", "outDir": "build/main", "rootDir": "src", - "moduleResolution": "node16", - "module": "node16", + "moduleResolution": "bundler", + "module": "esnext", "declaration": true, "inlineSourceMap": true, "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,