From ec9d2323def545db61db13862ffabc8be066c94e Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Sat, 24 Jan 2026 02:29:07 +0800 Subject: [PATCH 1/5] [explorer/rest] feat: added Account model --- explorer/rest/rest/model/Account.py | 78 +++++++++++++++++ explorer/rest/tests/model/test_Account.py | 101 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 explorer/rest/rest/model/Account.py create mode 100644 explorer/rest/tests/model/test_Account.py diff --git a/explorer/rest/rest/model/Account.py b/explorer/rest/rest/model/Account.py new file mode 100644 index 0000000000..3edd6a1c64 --- /dev/null +++ b/explorer/rest/rest/model/Account.py @@ -0,0 +1,78 @@ +class AccountView: # pylint: disable=too-many-locals,too-many-instance-attributes + def __init__( + self, + address, + public_key, + remote_address, + importance, + balance, + vested_balance, + mosaics, + harvested_fees, + harvested_blocks, + status, + remote_status, + last_harvested_height, + min_cosignatories, + cosignatory_of, + cosignatories + ): + """Create account view.""" + + # pylint: disable=too-many-arguments,too-many-positional-arguments + + self.address = address + self.public_key = public_key + self.remote_address = remote_address + self.importance = importance + self.balance = balance + self.vested_balance = vested_balance + self.mosaics = mosaics + self.harvested_fees = harvested_fees + self.harvested_blocks = harvested_blocks + self.status = status + self.remote_status = remote_status + self.last_harvested_height = last_harvested_height + self.min_cosignatories = min_cosignatories + self.cosignatory_of = cosignatory_of + self.cosignatories = cosignatories + + def __eq__(self, other): + return isinstance(other, AccountView) and all([ + self.address == other.address, + self.public_key == other.public_key, + self.remote_address == other.remote_address, + self.importance == other.importance, + self.balance == other.balance, + self.vested_balance == other.vested_balance, + self.mosaics == other.mosaics, + self.harvested_fees == other.harvested_fees, + self.harvested_blocks == other.harvested_blocks, + self.status == other.status, + self.remote_status == other.remote_status, + self.last_harvested_height == other.last_harvested_height, + self.min_cosignatories == other.min_cosignatories, + self.cosignatory_of == other.cosignatory_of, + self.cosignatories == other.cosignatories + ]) + + def to_dict(self): + """Formats the account info as a dictionary.""" + + return { + 'address': self.address, + 'publicKey': self.public_key, + 'remoteAddress': self.remote_address, + 'importance': self.importance, + 'balance': self.balance, + 'vestedBalance': self.vested_balance, + 'mosaics': self.mosaics, + 'harvestedFees': self.harvested_fees, + 'harvestedBlocks': self.harvested_blocks, + 'status': self.status, + 'remoteStatus': self.remote_status, + 'lastHarvestedHeight': self.last_harvested_height, + 'minCosignatories': self.min_cosignatories, + 'cosignatoryOf': self.cosignatory_of, + 'cosignatories': self.cosignatories + } diff --git a/explorer/rest/tests/model/test_Account.py b/explorer/rest/tests/model/test_Account.py new file mode 100644 index 0000000000..ebd91b802b --- /dev/null +++ b/explorer/rest/tests/model/test_Account.py @@ -0,0 +1,101 @@ +import unittest + +from rest.model.Account import AccountView + + +class AccountTest(unittest.TestCase): + @staticmethod + def _create_default_account_view(override=None): + account_view = AccountView( + address='NCXIQA4FF5JB6AMQ53NQ3ZMRD3X3PJEWDJJJIGHT', + public_key='107051C28A2C009A83AE0861CDBFF7C1CBAB387C964CC433F7D191D9C3115ED7', + remote_address='NA7HZVREMOJWCYQOHQYTMVVXOYFOFF4WX46FP65U', + importance=0.0477896226, + balance=1000.123456, + vested_balance=999.123456, + mosaics=[], + harvested_fees=1234.567890, + harvested_blocks=100, + status='LOCKED', + remote_status='ACTIVE', + last_harvested_height=1000, + min_cosignatories=None, + cosignatory_of=[], + cosignatories=[] + ) + + if override: + setattr(account_view, override[0], override[1]) + + return account_view + + def test_can_create_account_view(self): + # Act: + account_view = self._create_default_account_view() + + # Assert: + self.assertEqual('NCXIQA4FF5JB6AMQ53NQ3ZMRD3X3PJEWDJJJIGHT', account_view.address) + self.assertEqual('107051C28A2C009A83AE0861CDBFF7C1CBAB387C964CC433F7D191D9C3115ED7', account_view.public_key) + self.assertEqual('NA7HZVREMOJWCYQOHQYTMVVXOYFOFF4WX46FP65U', account_view.remote_address) + self.assertEqual(0.0477896226, account_view.importance) + self.assertEqual(1000.123456, account_view.balance) + self.assertEqual(999.123456, account_view.vested_balance) + self.assertEqual([], account_view.mosaics) + self.assertEqual(1234.567890, account_view.harvested_fees) + self.assertEqual(100, account_view.harvested_blocks) + self.assertEqual('LOCKED', account_view.status) + self.assertEqual('ACTIVE', account_view.remote_status) + self.assertEqual(1000, account_view.last_harvested_height) + self.assertIsNone(account_view.min_cosignatories) + self.assertEqual([], account_view.cosignatory_of) + self.assertEqual([], account_view.cosignatories) + + def test_can_convert_to_simple_dict(self): + # Arrange: + account_view = self._create_default_account_view() + + # Act: + account_view_dict = account_view.to_dict() + + # Assert: + self.assertEqual({ + 'address': 'NCXIQA4FF5JB6AMQ53NQ3ZMRD3X3PJEWDJJJIGHT', + 'publicKey': '107051C28A2C009A83AE0861CDBFF7C1CBAB387C964CC433F7D191D9C3115ED7', + 'remoteAddress': 'NA7HZVREMOJWCYQOHQYTMVVXOYFOFF4WX46FP65U', + 'importance': 0.0477896226, + 'balance': 1000.123456, + 'vestedBalance': 999.123456, + 'mosaics': [], + 'harvestedFees': 1234.567890, + 'harvestedBlocks': 100, + 'status': 'LOCKED', + 'remoteStatus': 'ACTIVE', + 'lastHarvestedHeight': 1000, + 'minCosignatories': None, + 'cosignatoryOf': [], + 'cosignatories': [] + }, account_view_dict) + + def test_eq_is_supported(self): + # Arrange: + account_view = self._create_default_account_view() + + # Assert: + self.assertEqual(account_view, self._create_default_account_view()) + self.assertNotEqual(account_view, None) + self.assertNotEqual(account_view, 'account_view') + self.assertNotEqual(account_view, self._create_default_account_view(('address', 'DIFFERENT_ADDRESS'))) + self.assertNotEqual(account_view, self._create_default_account_view(('public_key', 'DIFFERENT_KEY'))) + self.assertNotEqual(account_view, self._create_default_account_view(('remote_address', 'DIFFERENT_REMOTE'))) + self.assertNotEqual(account_view, self._create_default_account_view(('importance', 0.002))) + self.assertNotEqual(account_view, self._create_default_account_view(('balance', 999999.0))) + self.assertNotEqual(account_view, self._create_default_account_view(('vested_balance', 888888.0))) + self.assertNotEqual(account_view, self._create_default_account_view(('mosaics', [{'id': 'test'}]))) + self.assertNotEqual(account_view, self._create_default_account_view(('harvested_fees', 5000.0))) + self.assertNotEqual(account_view, self._create_default_account_view(('harvested_blocks', 99999))) + self.assertNotEqual(account_view, self._create_default_account_view(('status', 'UNLOCKED'))) + self.assertNotEqual(account_view, self._create_default_account_view(('remote_status', 'INACTIVE'))) + self.assertNotEqual(account_view, self._create_default_account_view(('last_harvested_height', 500000))) + self.assertNotEqual(account_view, self._create_default_account_view(('min_cosignatories', 2))) + self.assertNotEqual(account_view, self._create_default_account_view(('cosignatory_of', ['ADDRESS1']))) + self.assertNotEqual(account_view, self._create_default_account_view(('cosignatories', ['ADDRESS2']))) From b251db12de870f5e7abb3f70294f3d382b1dbf61 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 27 Jan 2026 09:47:51 +0800 Subject: [PATCH 2/5] [explorer/rest] feat: Added account endpoint, and unit test --- explorer/rest/rest/__init__.py | 27 ++++ explorer/rest/tests/test/DatabaseTestUtils.py | 119 ++++++++++++++++++ explorer/rest/tests/test_rest.py | 72 ++++++++++- 3 files changed, 217 insertions(+), 1 deletion(-) diff --git a/explorer/rest/rest/__init__.py b/explorer/rest/rest/__init__.py index 4e90c5e86e..dabab1abae 100644 --- a/explorer/rest/rest/__init__.py +++ b/explorer/rest/rest/__init__.py @@ -4,6 +4,7 @@ from flask import Flask, abort, jsonify, request from flask_cors import CORS +from symbolchain.CryptoTypes import PublicKey from zenlog import log from rest.facade.NemRestFacade import NemRestFacade @@ -79,6 +80,32 @@ def api_get_nem_blocks(): return jsonify(nem_api_facade.get_blocks(limit=limit, offset=offset, min_height=min_height, sort=sort)) + @app.route('/api/nem/account') + def api_get_nem_account(): + address = request.args.get('address', '').strip() or None + public_key = request.args.get('publicKey', '').strip() or None + + # Validate that exactly one of address or public_key is provided + if bool(address) == bool(public_key): + abort(400) + + try: + if address and not nem_api_facade.nem_db.network.is_valid_address_string(address): + abort(400) + + if public_key: + PublicKey(public_key) + + except ValueError: + abort(400) + + result = nem_api_facade.get_account(address=address, public_key=public_key) + + if not result: + abort(404) + + return jsonify(result) + def setup_error_handlers(app): @app.errorhandler(404) diff --git a/explorer/rest/tests/test/DatabaseTestUtils.py b/explorer/rest/tests/test/DatabaseTestUtils.py index bf88916239..13a62a2780 100644 --- a/explorer/rest/tests/test/DatabaseTestUtils.py +++ b/explorer/rest/tests/test/DatabaseTestUtils.py @@ -1,11 +1,14 @@ +import json import unittest from binascii import unhexlify from collections import namedtuple import testing.postgresql +from symbolchain.CryptoTypes import PublicKey from symbolchain.nem.Network import Address from rest.db.NemDatabase import NemDatabase +from rest.model.Account import AccountView from rest.model.Block import BlockView Block = namedtuple( @@ -22,6 +25,21 @@ 'size' ] ) +Account = namedtuple('Account', [ + 'address', + 'public_key', + 'remote_address', + 'importance', + 'balance', + 'vested_balance', + 'mosaics', + 'harvested_blocks', + 'status', + 'remote_status', + 'min_cosignatories', + 'cosignatory_of', + 'cosignatories' +]) DatabaseConfig = namedtuple('DatabaseConfig', ['database', 'user', 'password', 'host', 'port']) # region test data @@ -51,20 +69,85 @@ 752), ] +ACCOUNTS = [ + Account( + Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + PublicKey('b88221939ac920484753c738fafda87e82ff04b5e370c9456d85a0f12c6a5cca'), + None, + 0.123456, + 1000000, + 99999, + [{'quantity': 1000000, 'namespace': 'nem.xem'}], + 10, + 'LOCKED', + 'INACTIVE', + None, + None, + None) +] + BLOCK_VIEWS = [ BlockView(*BLOCKS[0]._replace(total_fees=102.0, signer=Address('NANEMOABLAGR72AZ2RV3V4ZHDCXW25XQ73O7OBT5'))), BlockView(*BLOCKS[1]._replace(total_fees=201.0, signer=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'))) ] +ACCOUNT_VIEWS = [ + AccountView( + address=str(ACCOUNTS[0].address), + public_key=str(ACCOUNTS[0].public_key) if ACCOUNTS[0].public_key else None, + remote_address=None, + importance=0.123456, + balance=1.0, + vested_balance=0.099999, + mosaics=[{ + 'namespace_name': 'nem.xem', + 'quantity': 1000000 + }], + harvested_fees=0.0, + harvested_blocks=ACCOUNTS[0].harvested_blocks, + status=ACCOUNTS[0].status, + remote_status=ACCOUNTS[0].remote_status, + last_harvested_height=0, + min_cosignatories=ACCOUNTS[0].min_cosignatories, + cosignatory_of=ACCOUNTS[0].cosignatory_of, + cosignatories=ACCOUNTS[0].cosignatories + ) +] + # endregion +# pylint: disable=duplicate-code def initialize_database(db_config, network_name): # Arrange + Act: with NemDatabase(db_config, network_name).connection() as connection: cursor = connection.cursor() # Create tables + cursor.execute( + ''' + CREATE TABLE IF NOT EXISTS accounts ( + id serial PRIMARY KEY, + address bytea NOT NULL UNIQUE, + public_key bytea, + remote_address bytea, + importance decimal(20, 10) DEFAULT 0, + balance bigint DEFAULT 0, + vested_balance bigint DEFAULT 0, + mosaics jsonb DEFAULT '[]'::jsonb, + harvested_fees bigint DEFAULT 0, + harvested_blocks bigint DEFAULT 0, + status varchar(8) DEFAULT NULL, + remote_status varchar(12) DEFAULT NULL, + last_harvested_height bigint DEFAULT 0, + min_cosignatories int DEFAULT NULL, + cosignatory_of bytea[] DEFAULT NULL, + cosignatories bytea[] DEFAULT NULL, + updated_at timestamp DEFAULT CURRENT_TIMESTAMP + ) + ''' + ) + cursor.execute(''' CREATE TABLE IF NOT EXISTS blocks ( id serial NOT NULL PRIMARY KEY, @@ -99,6 +182,42 @@ def initialize_database(db_config, network_name): ) ) + for account in ACCOUNTS: + cursor.execute( + ''' + INSERT INTO accounts ( + address, + public_key, + remote_address, + importance, + balance, + vested_balance, + mosaics, + harvested_blocks, + status, + remote_status, + min_cosignatories, + cosignatory_of, + cosignatories + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ''', ( + account.address.bytes, + account.public_key.bytes if account.public_key else None, + account.remote_address.bytes if account.remote_address else None, + account.importance, + account.balance, + account.vested_balance, + json.dumps(account.mosaics), + account.harvested_blocks, + account.status, + account.remote_status, + account.min_cosignatories, + [address.bytes for address in account.cosignatory_of] if account.cosignatory_of else None, + [address.bytes for address in account.cosignatories] if account.cosignatories else None + ) + ) + connection.commit() diff --git a/explorer/rest/tests/test_rest.py b/explorer/rest/tests/test_rest.py index 5b7971dd64..de4191703a 100644 --- a/explorer/rest/tests/test_rest.py +++ b/explorer/rest/tests/test_rest.py @@ -7,7 +7,7 @@ from rest import create_app -from .test.DatabaseTestUtils import BLOCK_VIEWS, DatabaseConfig, initialize_database +from .test.DatabaseTestUtils import ACCOUNT_VIEWS, BLOCK_VIEWS, DatabaseConfig, initialize_database DATABASE_CONFIG_INI = 'db_config.ini' @@ -186,3 +186,73 @@ def test_api_nem_blocks_invalid_sort(client): # pylint: disable=redefined-outer # endregion + + +# region /account + +def _assert_get_nem_account_success(client, expected_result, **query_params): # pylint: disable=redefined-outer-name + # Act: + response = client.get('/api/nem/account', query_string=query_params) + + # Assert: + _assert_status_code_and_headers(response, 200) + assert expected_result == response.json + + +def _assert_get_nem_account_bad_request(client, **query_params): # pylint: disable=redefined-outer-name + # Act: + response = client.get('/api/nem/account', query_string=query_params) + + # Assert: + _assert_status_code_and_headers(response, 400) + assert { + 'message': 'Bad request', + 'status': 400 + } == response.json + + +def test_api_nem_account_by_address(client): # pylint: disable=redefined-outer-name + _assert_get_nem_account_success( + client, + ACCOUNT_VIEWS[0].to_dict(), + address='NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO') + + +def test_api_nem_account_by_public_key(client): # pylint: disable=redefined-outer-name + _assert_get_nem_account_success( + client, + ACCOUNT_VIEWS[0].to_dict(), + publicKey='b88221939ac920484753c738fafda87e82ff04b5e370c9456d85a0f12c6a5cca') + + +def test_api_nem_account_missing_params(client): # pylint: disable=redefined-outer-name + _assert_get_nem_account_bad_request(client) + + +def test_api_nem_account_both_params(client): # pylint: disable=redefined-outer-name + _assert_get_nem_account_bad_request( + client, + address='NCXIQA4FF5JB6AMQ53NQ3ZMRD3X3PJEWDJJJIGHT', + publicKey='107051C28A2C009A83AE0861CDBFF7C1CBAB387C964CC433F7D191D9C3115ED7') + + +def test_api_nem_account_invalid_address(client): # pylint: disable=redefined-outer-name + _assert_get_nem_account_bad_request(client, address='INVALIDADDRESS') + + +def test_api_nem_account_invalid_public_key(client): # pylint: disable=redefined-outer-name,invalid-name + _assert_get_nem_account_bad_request(client, publicKey='INVALIDPUBLICKEY') + + +def test_api_nem_account_not_found(client): # pylint: disable=redefined-outer-name + # Act: + response = client.get('/api/nem/account?address=NANEMOABLAGR72AZ2RV3V4ZHDCXW25XQ73O7OBT5') + + # Assert: + _assert_status_code_and_headers(response, 404) + assert { + 'message': 'Resource not found', + 'status': 404 + } == response.json + +# endregion From 47b39fa257ec5674dca33dc00b4c4a94eddb25d8 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 27 Jan 2026 10:14:42 +0800 Subject: [PATCH 3/5] [explorer/rest] feat: Added get account query in database --- explorer/rest/rest/db/NemDatabase.py | 91 ++++++++++++++++++- explorer/rest/tests/db/test_NemDatabase.py | 36 +++++++- explorer/rest/tests/test/DatabaseTestUtils.py | 6 +- 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/explorer/rest/rest/db/NemDatabase.py b/explorer/rest/rest/db/NemDatabase.py index 997a25cfaa..52168d9e3c 100644 --- a/explorer/rest/rest/db/NemDatabase.py +++ b/explorer/rest/rest/db/NemDatabase.py @@ -1,9 +1,9 @@ from binascii import hexlify from symbolchain.CryptoTypes import PublicKey -from symbolchain.nem.Network import Network -from symbolchain.Network import NetworkLocator +from symbolchain.nem.Network import Address +from rest.model.Account import AccountView from rest.model.Block import BlockView from .DatabaseConnection import DatabaseConnectionPool @@ -20,9 +20,9 @@ def _format_xem_relative(amount): class NemDatabase(DatabaseConnectionPool): """Database containing Nem blockchain data.""" - def __init__(self, db_config, network_name): + def __init__(self, db_config, network): super().__init__(db_config) - self.network = NetworkLocator.find_by_name(Network.NETWORKS, network_name) + self.network = network def _create_block_view(self, result): harvest_public_key = PublicKey(_format_bytes(result[7])) @@ -38,6 +38,70 @@ def _create_block_view(self, result): size=result[9] ) + def _create_account_view(self, result): # pylint: disable=no-self-use,too-many-locals + ( + address, + public_key, + remote_address, + importance, + balance, + vested_balance, + mosaics, + harvested_fees, + harvested_blocks, + status, + remote_status, + last_harvested_height, + min_cosignatories, + cosignatory_of, + cosignatories + ) = result + + return AccountView( + address=str(Address(address)), + public_key=str(PublicKey(public_key)) if public_key else None, + remote_address=str(Address(remote_address)) if remote_address else None, + importance=importance, + balance=_format_xem_relative(balance), + vested_balance=_format_xem_relative(vested_balance), + mosaics=[{ + 'namespace_name': mosaic['namespace'], + 'quantity': mosaic['quantity'], + } for mosaic in mosaics], + harvested_fees=_format_xem_relative(harvested_fees), + harvested_blocks=harvested_blocks, + status=status, + remote_status=remote_status, + last_harvested_height=last_harvested_height, + min_cosignatories=min_cosignatories, + cosignatory_of=[str(Address(address)) for address in cosignatory_of] if cosignatory_of else None, + cosignatories=[str(Address(address)) for address in cosignatories] if cosignatories else None + ) + + def _generate_account_query(self, where_condition): # pylint: disable=no-self-use + """Base account query.""" + + return f''' + SELECT + address, + public_key, + remote_address, + importance::float, + balance, + vested_balance, + mosaics, + harvested_fees, + harvested_blocks, + status, + remote_status, + last_harvested_height, + min_cosignatories, + cosignatory_of, + cosignatories + FROM accounts + WHERE {where_condition} + ''' + def get_block(self, height): """Gets block by height in database.""" @@ -67,3 +131,22 @@ def get_blocks(self, limit, offset, min_height, sort): results = cursor.fetchall() return [self._create_block_view(result) for result in results] + + def get_account(self, address=None, public_key=None): + """Gets account by address or public key.""" + + if address: + where_clause = 'address = %s' + param = address.bytes + else: + where_clause = 'public_key = %s' + param = public_key.bytes + + sql = self._generate_account_query(where_clause) + + with self.connection() as connection: + cursor = connection.cursor() + cursor.execute(sql, (param,)) + result = cursor.fetchone() + + return self._create_account_view(result) if result else None diff --git a/explorer/rest/tests/db/test_NemDatabase.py b/explorer/rest/tests/db/test_NemDatabase.py index 208089b4f3..d6c4bc46ad 100644 --- a/explorer/rest/tests/db/test_NemDatabase.py +++ b/explorer/rest/tests/db/test_NemDatabase.py @@ -2,7 +2,7 @@ from rest.db.NemDatabase import NemDatabase -from ..test.DatabaseTestUtils import BLOCK_VIEWS, DatabaseTestBase +from ..test.DatabaseTestUtils import ACCOUNT_VIEWS, ACCOUNTS, BLOCK_VIEWS, DatabaseTestBase BlockQueryParams = namedtuple('BlockQueryParams', ['limit', 'offset', 'min_height', 'sort']) @@ -12,14 +12,18 @@ EXPECTED_BLOCK_VIEW_2 = BLOCK_VIEWS[1] +EXPECTED_ACCOUNT_VIEW_1 = ACCOUNT_VIEWS[0] + # endregion class NemDatabaseTest(DatabaseTestBase): + # region block + def _assert_can_query_block_by_height(self, height, expected_block): # Arrange: - nem_db = NemDatabase(self.db_config, self.network_name) + nem_db = NemDatabase(self.db_config, self.network) # Act: block_view = nem_db.get_block(height) @@ -29,7 +33,7 @@ def _assert_can_query_block_by_height(self, height, expected_block): def _assert_can_query_blocks_with_filter(self, query_params, expected_blocks): # Arrange: - nem_db = NemDatabase(self.db_config, self.network_name) + nem_db = NemDatabase(self.db_config, self.network) # Act: blocks_view = nem_db.get_blocks(query_params.limit, query_params.offset, query_params.min_height, query_params.sort) @@ -66,3 +70,29 @@ def test_can_query_blocks_sorted_by_height_asc(self): def test_can_query_blocks_sorted_by_height_desc(self): self._assert_can_query_blocks_with_filter(BlockQueryParams(10, 0, 0, 'desc'), [EXPECTED_BLOCK_VIEW_2, EXPECTED_BLOCK_VIEW_1]) + + # endregion + + # region account + + def test_can_query_account_by_address(self): + # Arrange: + nem_db = NemDatabase(self.db_config, self.network) + + # Act: + account_view = nem_db.get_account(address=ACCOUNTS[0].address) + + # Assert: + self.assertEqual(EXPECTED_ACCOUNT_VIEW_1, account_view) + + def test_can_query_account_by_public_key(self): + # Arrange: + nem_db = NemDatabase(self.db_config, self.network) + + # Act: + account_view = nem_db.get_account(public_key=ACCOUNTS[0].public_key) + + # Assert: + self.assertEqual(EXPECTED_ACCOUNT_VIEW_1, account_view) + + # endregion diff --git a/explorer/rest/tests/test/DatabaseTestUtils.py b/explorer/rest/tests/test/DatabaseTestUtils.py index 13a62a2780..1c48481a13 100644 --- a/explorer/rest/tests/test/DatabaseTestUtils.py +++ b/explorer/rest/tests/test/DatabaseTestUtils.py @@ -5,7 +5,7 @@ import testing.postgresql from symbolchain.CryptoTypes import PublicKey -from symbolchain.nem.Network import Address +from symbolchain.nem.Network import Address, Network from rest.db.NemDatabase import NemDatabase from rest.model.Account import AccountView @@ -226,8 +226,8 @@ class DatabaseTestBase(unittest.TestCase): def setUp(self): self.postgresql = testing.postgresql.Postgresql() self.db_config = DatabaseConfig(**self.postgresql.dsn(), password='') - self.network_name = 'mainnet' - initialize_database(self.db_config, self.network_name) + self.network = Network.MAINNET + initialize_database(self.db_config, self.network) def tearDown(self): self.postgresql.stop() From 7b14041df9f723b18e1b6292e35d2e2fa02f4438 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Tue, 27 Jan 2026 10:24:57 +0800 Subject: [PATCH 4/5] [explorer/rest] feat: Added get account logic in facade --- explorer/rest/rest/facade/NemRestFacade.py | 17 ++++++- .../rest/tests/facade/test_NemRestFacade.py | 47 +++++++++++++++---- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/explorer/rest/rest/facade/NemRestFacade.py b/explorer/rest/rest/facade/NemRestFacade.py index 2afaeded2a..19fa41f867 100644 --- a/explorer/rest/rest/facade/NemRestFacade.py +++ b/explorer/rest/rest/facade/NemRestFacade.py @@ -1,3 +1,7 @@ +from symbolchain.CryptoTypes import PublicKey +from symbolchain.nem.Network import Address, Network +from symbolchain.Network import NetworkLocator + from rest.db.NemDatabase import NemDatabase @@ -7,7 +11,8 @@ class NemRestFacade: def __init__(self, db_config, network_name): """Creates a facade object.""" - self.nem_db = NemDatabase(db_config, network_name) + self.network = NetworkLocator.find_by_name(Network.NETWORKS, network_name) + self.nem_db = NemDatabase(db_config, self.network) def get_block(self, height): """Gets block by height.""" @@ -22,3 +27,13 @@ def get_blocks(self, limit, offset, min_height, sort): blocks = self.nem_db.get_blocks(limit, offset, min_height, sort) return [block.to_dict() for block in blocks] + + def get_account(self, address=None, public_key=None): + """Gets account by address or public key.""" + + address_obj = Address(address) if address else None + public_key_obj = PublicKey(public_key) if public_key else None + + account = self.nem_db.get_account(address=address_obj, public_key=public_key_obj) + + return account.to_dict() if account else None diff --git a/explorer/rest/tests/facade/test_NemRestFacade.py b/explorer/rest/tests/facade/test_NemRestFacade.py index e546004dc6..0428d6e97d 100644 --- a/explorer/rest/tests/facade/test_NemRestFacade.py +++ b/explorer/rest/tests/facade/test_NemRestFacade.py @@ -1,7 +1,7 @@ from rest.facade.NemRestFacade import NemRestFacade from ..db.test_NemDatabase import BlockQueryParams -from ..test.DatabaseTestUtils import BLOCK_VIEWS, DatabaseTestBase +from ..test.DatabaseTestUtils import ACCOUNT_VIEWS, BLOCK_VIEWS, DatabaseTestBase # region test data @@ -9,27 +9,29 @@ EXPECTED_BLOCK_2 = BLOCK_VIEWS[1].to_dict() +EXPECTED_ACCOUNT_1 = ACCOUNT_VIEWS[0].to_dict() + # endregion class TestNemRestFacade(DatabaseTestBase): - def _assert_can_retrieve_block(self, height, expected_block): - # Arrange: - nem_rest_facade = NemRestFacade(self.db_config, self.network_name) + def setUp(self): + super().setUp() + self.nem_rest_facade = NemRestFacade(self.db_config, 'mainnet') + + # region block + def _assert_can_retrieve_block(self, height, expected_block): # Act: - block = nem_rest_facade.get_block(height) + block = self.nem_rest_facade.get_block(height) # Assert: self.assertEqual(expected_block, block) def _assert_can_retrieve_blocks(self, query_params, expected_blocks): - # Arrange: - nem_rest_facade = NemRestFacade(self.db_config, self.network_name) - # Act: - blocks = nem_rest_facade.get_blocks(query_params.limit, query_params.offset, query_params.min_height, query_params.sort) + blocks = self.nem_rest_facade.get_blocks(query_params.limit, query_params.offset, query_params.min_height, query_params.sort) # Assert: self.assertEqual(expected_blocks, blocks) @@ -57,3 +59,30 @@ def test_blocks_sorted_by_height_asc(self): def test_blocks_sorted_by_height_desc(self): self._assert_can_retrieve_blocks(BlockQueryParams(10, 0, 0, 'desc'), [EXPECTED_BLOCK_2, EXPECTED_BLOCK_1]) + + # endregion + + # region account + + def test_can_retrieve_account_by_address(self): + # Act: + account = self.nem_rest_facade.get_account(address='NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO') + + # Assert: + self.assertEqual(EXPECTED_ACCOUNT_1, account) + + def test_can_retrieve_account_by_public_key(self): + # Act: + account = self.nem_rest_facade.get_account(public_key='b88221939ac920484753c738fafda87e82ff04b5e370c9456d85a0f12c6a5cca') + + # Assert: + self.assertEqual(EXPECTED_ACCOUNT_1, account) + + def test_returns_none_for_nonexistent_account(self): + # Act: + account = self.nem_rest_facade.get_account(address='NAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + + # Assert: + self.assertIsNone(account) + + # endregion From 30dafc05d3b86d069c0473f46e97e181c2171ce7 Mon Sep 17 00:00:00 2001 From: AnthonyLaw Date: Thu, 29 Jan 2026 16:28:10 +0800 Subject: [PATCH 5/5] [explorer/rest] fix: improvement --- explorer/rest/rest/__init__.py | 18 +++++++------ explorer/rest/rest/db/NemDatabase.py | 27 ++++++++++++------- explorer/rest/rest/facade/NemRestFacade.py | 14 ++++++---- explorer/rest/tests/db/test_NemDatabase.py | 4 +-- .../rest/tests/facade/test_NemRestFacade.py | 15 ++++++++--- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/explorer/rest/rest/__init__.py b/explorer/rest/rest/__init__.py index dabab1abae..baaf61361d 100644 --- a/explorer/rest/rest/__init__.py +++ b/explorer/rest/rest/__init__.py @@ -89,17 +89,19 @@ def api_get_nem_account(): if bool(address) == bool(public_key): abort(400) - try: - if address and not nem_api_facade.nem_db.network.is_valid_address_string(address): - abort(400) + if address and not nem_api_facade.nem_db.network.is_valid_address_string(address): + abort(400) - if public_key: + if public_key: + try: PublicKey(public_key) + except ValueError: + abort(400) - except ValueError: - abort(400) - - result = nem_api_facade.get_account(address=address, public_key=public_key) + if address: + result = nem_api_facade.get_account_by_address(address) + else: + result = nem_api_facade.get_account_by_public_key(public_key) if not result: abort(404) diff --git a/explorer/rest/rest/db/NemDatabase.py b/explorer/rest/rest/db/NemDatabase.py index 52168d9e3c..df1d8e593d 100644 --- a/explorer/rest/rest/db/NemDatabase.py +++ b/explorer/rest/rest/db/NemDatabase.py @@ -132,21 +132,30 @@ def get_blocks(self, limit, offset, min_height, sort): return [self._create_block_view(result) for result in results] - def get_account(self, address=None, public_key=None): - """Gets account by address or public key.""" + def get_account_by_address(self, address): + """Gets account by address.""" - if address: - where_clause = 'address = %s' - param = address.bytes - else: - where_clause = 'public_key = %s' - param = public_key.bytes + where_clause = 'address = %s' sql = self._generate_account_query(where_clause) with self.connection() as connection: cursor = connection.cursor() - cursor.execute(sql, (param,)) + cursor.execute(sql, (address.bytes,)) + result = cursor.fetchone() + + return self._create_account_view(result) if result else None + + def get_account_by_public_key(self, public_key): + """Gets account by public key.""" + + where_clause = 'public_key = %s' + + sql = self._generate_account_query(where_clause) + + with self.connection() as connection: + cursor = connection.cursor() + cursor.execute(sql, (public_key.bytes,)) result = cursor.fetchone() return self._create_account_view(result) if result else None diff --git a/explorer/rest/rest/facade/NemRestFacade.py b/explorer/rest/rest/facade/NemRestFacade.py index 19fa41f867..3ab0703041 100644 --- a/explorer/rest/rest/facade/NemRestFacade.py +++ b/explorer/rest/rest/facade/NemRestFacade.py @@ -28,12 +28,16 @@ def get_blocks(self, limit, offset, min_height, sort): return [block.to_dict() for block in blocks] - def get_account(self, address=None, public_key=None): - """Gets account by address or public key.""" + def get_account_by_address(self, address): + """Gets account by address.""" - address_obj = Address(address) if address else None - public_key_obj = PublicKey(public_key) if public_key else None + account = self.nem_db.get_account_by_address(Address(address)) - account = self.nem_db.get_account(address=address_obj, public_key=public_key_obj) + return account.to_dict() if account else None + + def get_account_by_public_key(self, public_key): + """Gets account by public key.""" + + account = self.nem_db.get_account_by_public_key(PublicKey(public_key)) return account.to_dict() if account else None diff --git a/explorer/rest/tests/db/test_NemDatabase.py b/explorer/rest/tests/db/test_NemDatabase.py index d6c4bc46ad..c449e7daad 100644 --- a/explorer/rest/tests/db/test_NemDatabase.py +++ b/explorer/rest/tests/db/test_NemDatabase.py @@ -80,7 +80,7 @@ def test_can_query_account_by_address(self): nem_db = NemDatabase(self.db_config, self.network) # Act: - account_view = nem_db.get_account(address=ACCOUNTS[0].address) + account_view = nem_db.get_account_by_address(address=ACCOUNTS[0].address) # Assert: self.assertEqual(EXPECTED_ACCOUNT_VIEW_1, account_view) @@ -90,7 +90,7 @@ def test_can_query_account_by_public_key(self): nem_db = NemDatabase(self.db_config, self.network) # Act: - account_view = nem_db.get_account(public_key=ACCOUNTS[0].public_key) + account_view = nem_db.get_account_by_public_key(public_key=ACCOUNTS[0].public_key) # Assert: self.assertEqual(EXPECTED_ACCOUNT_VIEW_1, account_view) diff --git a/explorer/rest/tests/facade/test_NemRestFacade.py b/explorer/rest/tests/facade/test_NemRestFacade.py index 0428d6e97d..d0d975c81d 100644 --- a/explorer/rest/tests/facade/test_NemRestFacade.py +++ b/explorer/rest/tests/facade/test_NemRestFacade.py @@ -66,21 +66,28 @@ def test_blocks_sorted_by_height_desc(self): def test_can_retrieve_account_by_address(self): # Act: - account = self.nem_rest_facade.get_account(address='NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO') + account = self.nem_rest_facade.get_account_by_address(address='NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO') # Assert: self.assertEqual(EXPECTED_ACCOUNT_1, account) def test_can_retrieve_account_by_public_key(self): # Act: - account = self.nem_rest_facade.get_account(public_key='b88221939ac920484753c738fafda87e82ff04b5e370c9456d85a0f12c6a5cca') + account = self.nem_rest_facade.get_account_by_public_key(public_key='b88221939ac920484753c738fafda87e82ff04b5e370c9456d85a0f12c6a5cca') # Assert: self.assertEqual(EXPECTED_ACCOUNT_1, account) - def test_returns_none_for_nonexistent_account(self): + def test_returns_none_for_nonexistent_account_address(self): # Act: - account = self.nem_rest_facade.get_account(address='NAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + account = self.nem_rest_facade.get_account_by_address(address='NAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') + + # Assert: + self.assertIsNone(account) + + def test_returns_none_for_nonexistent_account_public_key(self): + # Act: + account = self.nem_rest_facade.get_account_by_public_key(public_key='AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') # Assert: self.assertIsNone(account)