diff --git a/README.md b/README.md index 1092b8d..bd29928 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ * [Nodes](#nodes) * [Configuration file](#configuration-file) * [Service](#service) - * [Account](#account) + * [Batch](#batch) * [Development](#development) * [Requirements](#development-requirements) * [Docker](#docker) @@ -116,6 +116,70 @@ $ remme account get-balance \ } ``` +### Batch + +Get a list of batches — ``remme batch get-list``: + +| Arguments | Type | Required | Description | +| :-------: | :----: | :-------: | --------------------------------------------------- | +| ids | String | No | Identifiers to get a list of batches by. | +| start | String | No | Parameter to list batches starting from. | +| limit | Integer| No | Parameter to limit amount of batches. | +| head | String | No | Block identifier to get a list of batches from. | +| reverse | String | No | Parameter to reverse result. | +| node-url | String | No | Node URL to apply the command to. | + +```bash +$ remme batch get-list \ + --ids=[c2eeb94926d3432e41cb5ceed078f78466389e4fe685ec958021a1368f634c035072e434e5d0ff64820dd01fde6e5afc67eb5a9ae6c48d3983ff43abe98aef6b] \ + --node-url=159.89.104.9 +{ + "result": { + "data": [ + { + "header": { + "signer_public_key": "02d1fbda50dbcd0d3c286a6a9fa71aa7ce2d97159b90ddd463e0816422d621e135", + "transaction_ids": [ + "0317216042416f38087fed16bd4a0e1ea90ca240d22547495f8eb0d23c9b680e0d763199e7864f2384256d16d5ea56ca31ee8087f6c579434dcd454d97140faa" + ] + }, + "header_signature": "c2eeb94926d3432e41cb5ceed078f78466389e4fe685ec958021a1368f634c035072e434e5d0ff64820dd01fde6e5afc67eb5a9ae6c48d3983ff43abe98aef6b", + "trace": false, + "transactions": [ + { + "header": { + "batcher_public_key": "02d1fbda50dbcd0d3c286a6a9fa71aa7ce2d97159b90ddd463e0816422d621e135", + "dependencies": [], + "family_name": "block_info", + "family_version": "1.0", + "inputs": [ + "00b10c0100000000000000000000000000000000000000000000000000000000000000", + "00b10c00" + ], + "nonce": "", + "outputs": [ + "00b10c0100000000000000000000000000000000000000000000000000000000000000", + "00b10c00" + ], + "payload_sha512": "68073292cc2595ed11eac0a9b61c9a54cbee641209ab75ce83f8a4ccb78c64fb77c78d05d54f52624664a4725f5c57cd89f665d22dfcebe9fe11acd28c48884d", + "signer_public_key": "02d1fbda50dbcd0d3c286a6a9fa71aa7ce2d97159b90ddd463e0816422d621e135" + }, + "header_signature": "0317216042416f38087fed16bd4a0e1ea90ca240d22547495f8eb0d23c9b680e0d763199e7864f2384256d16d5ea56ca31ee8087f6c579434dcd454d97140faa", + "payload": "CtICCHoSgAFkZGJhZTU2ZDZkYzJiMDE2MjQyODc1NWE1MmJkZDdhYzkzODJjNGRiOTk3MTkzMWEyMTA5MmZhYjA1ZGIwZDJjNWYzMzE2M2NlODZhZTRlNzY0MGEyYmY0ZWNhNWJiMzdlZTM2NzU1N2M2YTc4ZDMwOGQ0ZGYyMWQxY2Y3Y2EyORpCMDJkMWZiZGE1MGRiY2QwZDNjMjg2YTZhOWZhNzFhYTdjZTJkOTcxNTliOTBkZGQ0NjNlMDgxNjQyMmQ2MjFlMTM1IoABMGU4ODRkYjdiYjRkN2ZkYTdhODc2YWQ4NzVmNTA0NWFjNmY1MDUwZDZlOGVkYzk2NjRhZjg0NWI2YTM0Mzk4ODc4YmQ2MDA2ODgyY2JjMTMzNTMzYzQ1OWRhZDE3ZDExODMwNGViZjNjOGFmMmE2ZGZhNWViNWIzMjc4MTViY2QoyPb85QU=" + } + ] + } + ], + "head": "b6ac30a480b237c8796fa4903354af835c85d78e781644265bc7a202b61d750c465790865b28ce69473d02b4e056d823194fd29e3f986800f0e7316865bffd7b", + "paging": { + "limit": null, + "next": "", + "start": null + } + } +} +``` + ## Development

Requirements

diff --git a/cli/batch/__init__.py b/cli/batch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cli/batch/cli.py b/cli/batch/cli.py new file mode 100644 index 0000000..4c2144f --- /dev/null +++ b/cli/batch/cli.py @@ -0,0 +1,105 @@ +""" +Provide implementation of the command line interface's batch commands. +""" +import asyncio +import sys + +import click +from remme import Remme + +from cli.batch.forms import ListBatchesForm +from cli.batch.help import ( + BATCH_HEAD_ARGUMENT_HELP_MESSAGE, + BATCH_IDS_ARGUMENT_HELP_MESSAGE, + BATCH_LIMIT_ARGUMENT_HELP_MESSAGE, + BATCH_REVERSE_ARGUMENT_HELP_MESSAGE, + BATCH_START_ARGUMENT_HELP_MESSAGE, +) +from cli.batch.service import Batch +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + NODE_URL_ARGUMENT_HELP_MESSAGE, +) +from cli.utils import ( + default_node_url, + dict_to_pretty_json, + print_errors, + print_result, +) + +loop = asyncio.get_event_loop() + + +@click.group('batch', chain=True) +def batch_commands(): + """ + Provide commands for working with batches. + """ + pass + + +def _split_ids(ctx, param, value): + """ + Convert string to list. Remove '[', ']' and split by ','. + """ + if value is None: + return + + if value[0] != '[' or value[-1] != ']': + click.echo(dict_to_pretty_json({ + 'ids': ['Not a valid list.'], + })) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + value = value[1:-1] + values = [c.strip() for c in value.split(',')] + + return values + + +@click.option('--ids', type=str, required=False, help=BATCH_IDS_ARGUMENT_HELP_MESSAGE, callback=_split_ids) +@click.option('--start', type=str, required=False, help=BATCH_START_ARGUMENT_HELP_MESSAGE) +@click.option('--limit', type=int, required=False, help=BATCH_LIMIT_ARGUMENT_HELP_MESSAGE) +@click.option('--head', type=str, required=False, help=BATCH_HEAD_ARGUMENT_HELP_MESSAGE) +@click.option('--reverse', type=str, required=False, help=BATCH_REVERSE_ARGUMENT_HELP_MESSAGE) +@click.option('--node-url', type=str, required=False, help=NODE_URL_ARGUMENT_HELP_MESSAGE, default=default_node_url()) +@batch_commands.command('get-list') +def get_batches(ids, start, limit, head, reverse, node_url): + """ + Get a list of batches. + """ + arguments, errors = ListBatchesForm().load({ + 'ids': ids, + 'start': start, + 'limit': limit, + 'head': head, + 'reverse': reverse, + 'node_url': node_url, + }) + + if errors: + print_errors(errors) + sys.exit(FAILED_EXIT_FROM_COMMAND_CODE) + + ids = arguments.get('ids') + start = arguments.get('start') + limit = arguments.get('limit') + head = arguments.get('head') + reverse = arguments.get('reverse') + node_url = arguments.get('node_url') + + remme = Remme(network_config={ + 'node_address': str(node_url) + ':8080', + }) + + query = { + 'ids': ids, + 'start': start, + 'limit': limit, + 'head': head, + 'reverse': reverse, + } + + batch_service = Batch(service=remme) + batches = loop.run_until_complete(batch_service.get_list(query)) + + print_result(batches) diff --git a/cli/batch/forms.py b/cli/batch/forms.py new file mode 100644 index 0000000..1fbb421 --- /dev/null +++ b/cli/batch/forms.py @@ -0,0 +1,87 @@ +""" +Provide forms for command line interface's batch commands. +""" +import re + +from marshmallow import ( + Schema, + ValidationError, + fields, + validates, +) + +from cli.constants import ( + DOMAIN_NAME_REGEXP, + HEADER_SIGNATURE_REGEXP, +) + + +class ListBatchesForm(Schema): + """ + List batches validation form. + """ + + ids = fields.List(fields.String(), allow_none=True, required=False) + start = fields.String(allow_none=True, required=False) + limit = fields.Integer(allow_none=True, required=False) + head = fields.String(allow_none=True, required=False) + reverse = fields.String(allow_none=True, required=False) + node_url = fields.String(allow_none=True, required=False) + + @validates('ids') + def validate_ids(self, ids): + """ + Validate batch identifiers. + """ + if ids is not None: + for batch_id in ids: + if re.match(pattern=HEADER_SIGNATURE_REGEXP, string=batch_id) is None: + raise ValidationError(f'The following batch id `{batch_id}` is invalid.') + + @validates('start') + def validate_start(self, start): + """ + Validate start. + """ + if start is not None and re.match(pattern=HEADER_SIGNATURE_REGEXP, string=start) is None: + raise ValidationError(f'The following batch id `{start}` is invalid.') + + @validates('limit') + def validate_limit(self, limit): + """ + Validate limit. + """ + if limit is not None and limit < 0: + raise ValidationError("Limit can't be negative.") + + @validates('head') + def validate_head(self, head): + """ + Validate head. + """ + if head is not None and re.match(pattern=HEADER_SIGNATURE_REGEXP, string=head) is None: + raise ValidationError(f'The following block id `{head}` is invalid.') + + @validates('reverse') + def validate_reverse(self, reverse): + """ + Validate reverse. + """ + if reverse is not None and reverse not in ('true', 'false'): + raise ValidationError("Invalid reverse field. Should be either 'true' or 'false'.") + + @validates('node_url') + def validate_node_url(self, node_url): + """ + Validate node URL. + + If node URL is localhost, it means client didn't passed any URL, so nothing to validate. + """ + if node_url == 'localhost': + return + + if 'http' in node_url or 'https' in node_url: + raise ValidationError(f'Pass the following node URL `{node_url}` without protocol (http, https, etc.).') + + if re.match(pattern=DOMAIN_NAME_REGEXP, string=node_url) is None: + raise ValidationError(f'The following node URL `{node_url}` is invalid.') diff --git a/cli/batch/help.py b/cli/batch/help.py new file mode 100644 index 0000000..1c6e3a4 --- /dev/null +++ b/cli/batch/help.py @@ -0,0 +1,8 @@ +""" +Provide help messages for command line interface's batch commands. +""" +BATCH_IDS_ARGUMENT_HELP_MESSAGE = 'Identifiers to get a list of batches by.' +BATCH_START_ARGUMENT_HELP_MESSAGE = 'Parameter to list batches starting from.' +BATCH_LIMIT_ARGUMENT_HELP_MESSAGE = 'Parameter to limit amount of batches.' +BATCH_HEAD_ARGUMENT_HELP_MESSAGE = 'Block identifier to get a list of batches from.' +BATCH_REVERSE_ARGUMENT_HELP_MESSAGE = 'Parameter to reverse result.' diff --git a/cli/batch/interfaces.py b/cli/batch/interfaces.py new file mode 100644 index 0000000..d5335b7 --- /dev/null +++ b/cli/batch/interfaces.py @@ -0,0 +1,21 @@ +""" +Provide implementation of the batch interfaces. +""" + + +class BatchInterface: + """ + Implements batch interface. + """ + + async def get_list(self, query=None): + """ + Get a list of batches from REMChain. + + Arguments: + query (dict, optional): dictionary with specific parameters + + Returns: + List of batches. + """ + pass diff --git a/cli/batch/service.py b/cli/batch/service.py new file mode 100644 index 0000000..d66a1e7 --- /dev/null +++ b/cli/batch/service.py @@ -0,0 +1,34 @@ +""" +Provide implementation of the batch service. +""" +from accessify import implements + +from cli.batch.interfaces import BatchInterface + + +@implements(BatchInterface) +class Batch: + """ + Implements batch interface. + """ + + def __init__(self, service): + """ + Constructor. + + Arguments: + service: object to interact with Remme core API. + """ + self.service = service + + async def get_list(self, query=None): + """ + Get all batches from REMChain. + + Arguments: + query (dict, optional): dictionary with specific parameters + + Returns: + List of batches. + """ + return await self.service.blockchain_info.get_batches(query=query) diff --git a/cli/entrypoint.py b/cli/entrypoint.py index e45563e..eb76ddd 100644 --- a/cli/entrypoint.py +++ b/cli/entrypoint.py @@ -4,6 +4,7 @@ import click from cli.account.cli import account_commands +from cli.batch.cli import batch_commands @click.group() @@ -17,3 +18,4 @@ def cli(): cli.add_command(account_commands) +cli.add_command(batch_commands) diff --git a/tests/batch/test_get_batches.py b/tests/batch/test_get_batches.py new file mode 100644 index 0000000..204a9b7 --- /dev/null +++ b/tests/batch/test_get_batches.py @@ -0,0 +1,276 @@ +""" +Provide tests for command line interface's get batches command. +""" +import json + +from click.testing import CliRunner + +from cli.constants import ( + FAILED_EXIT_FROM_COMMAND_CODE, + NODE_IP_ADDRESS_FOR_TESTING, + PASSED_EXIT_FROM_COMMAND_CODE, +) +from cli.entrypoint import cli + +BATCH_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE = \ + '93225596c6dcd8e520846700e052651728b1c85d1ae2fcf44e14bb628d97b4c9' \ + '6f314618ab3cea3e43775f9874089db6040e86cf5ab3d7be50454bef1d6bc73b' + +BATCH_HEAD_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE = \ + 'eecfeb7fe9c82193ba7e4b731cb8e3aec1b384d9fcb1f481f812573319093801' \ + '56da6ee44533c77b95bd311f1a72c303a8b60a3415570e3d0d369ff2b210e829' + + +def test_get_batches_by_ids(): + """ + Case: get batches by identifiers. + Expect: list with batch data is returned, header, header signature, transactions are presented. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--ids', + '[' + BATCH_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE + ']', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + command_response = json.loads(result.output) + data = command_response.get('result').get('data') + first_batch = data[0] + transactions = first_batch.get('transactions') + header = first_batch.get('header') + header_signature = first_batch.get('header_signature') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert BATCH_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE == header_signature + assert len(data) == 1 + assert len(transactions) > 0 + assert header is not None + + +def test_get_batches_invalid_ids(): + """ + Case: get batches by invalid identifiers. + Expect: the following batch id is invalid error message. + """ + invalid_batch_identifier = '152f3be91d8238538a83077ec8cd5d1d937767c0930eea61b59151b0dfa7c5a1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--ids', + '[' + invalid_batch_identifier + ']', + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert 'The following batch id `{id}` is invalid.'.format(id=invalid_batch_identifier) in result.output + + +def test_get_batches_invalid_list_ids(): + """ + Case: get batches by invalid list of identifiers. + Expect: not a valid list error message. + """ + invalid_identifiers_list = '152f3be91d8238538a83077ec8cd5d1d937767c0930eea61b59151b0dfa7c5a1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--ids', + invalid_identifiers_list, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert 'Not a valid list.' in result.output + + +def test_get_batches_by_start(): + """ + Case: get batches by start. + Expect: list with batch data is returned, header, header signature, transactions are presented. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--start', + BATCH_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + command_response = json.loads(result.output) + data = command_response.get('result').get('data') + first_batch = data[0] + transactions = first_batch.get('transactions') + header = first_batch.get('header') + header_signature = first_batch.get('header_signature') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert BATCH_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE == header_signature + assert len(data) >= 1 + assert len(transactions) > 0 + assert header is not None + + +def test_get_batches_invalid_start(): + """ + Case: get batches by invalid start. + Expect: the following batch id is invalid error message. + """ + invalid_start_identifier = '152f3be91d8238538a83077ec8cd5d1d937767c0930eea61b59151b0dfa7c5a1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--start', + invalid_start_identifier, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert 'The following batch id `{id}` is invalid.'.format(id=invalid_start_identifier) in result.output + + +def test_get_batches_limit(): + """ + Case: get batches with limit field. + Expect: limited list with batch data is returned. + """ + limit = 2 + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--limit', + limit, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + command_response = json.loads(result.output) + data = command_response.get('result').get('data') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert len(data) <= limit + + +def test_get_batches_negative_limit(): + """ + Case: get batches with negative limit. + Expect: limit can't be negative error message. + """ + limit = -1 + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--limit', + limit, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert "Limit can't be negative." in result.output + + +def test_get_batches_invalid_limit(): + """ + Case: get batches with invalid limit. + Expect: invalid integer error message. + """ + limit = '152f3be91d8238538a83077ec8cd5d1d937767c0930eea61b59151b0dfa7c5a1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--limit', + limit, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert limit + ' is not a valid integer' in result.output + + +def test_get_batches_by_head(): + """ + Case: get batches by head. + Expect: list with batch data is returned, header, header signature, transactions are presented. + """ + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--head', + BATCH_HEAD_IDENTIFIER_PRESENTED_ON_THE_TEST_NODE, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + command_response = json.loads(result.output) + data = command_response.get('result').get('data') + first_batch = data[0] + transactions = first_batch.get('transactions') + header = first_batch.get('header') + + assert PASSED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert len(data) >= 1 + assert len(transactions) > 0 + assert header is not None + + +def test_get_batches_invalid_head(): + """ + Case: get batches by invalid head. + Expect: the following block id is invalid error message. + """ + invalid_head_identifier = '152f3be91d8238538a83077ec8cd5d1d937767c0930eea61b59151b0dfa7c5a1' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--head', + invalid_head_identifier, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert 'The following block id `{id}` is invalid.'.format(id=invalid_head_identifier) in result.output + + +def test_get_batches_invalid_reverse(): + """ + Case: get batches with invalid reverse field. + Expect: invalid reverse field error message. + """ + invalid_reverse_identifier = 'invalid_reverse' + + runner = CliRunner() + result = runner.invoke(cli, [ + 'batch', + 'get-list', + '--reverse', + invalid_reverse_identifier, + '--node-url', + NODE_IP_ADDRESS_FOR_TESTING, + ]) + + assert FAILED_EXIT_FROM_COMMAND_CODE == result.exit_code + assert "Invalid reverse field. Should be either 'true' or 'false'." in result.output