Skip to content

Commit 62b68f9

Browse files
Create new aptos collector (#62)
* Refactor the HTTP interface to include get request * Fix test interfaces, fix pylint * Add first version of Aptos collector * Add Aptos to the registries * Working version of Aptos * Fix styling * Add test cases for Aptos * Fix name for cached_rest_api_get * Fix name for cached_json_rest_api_get
1 parent 2a73342 commit 62b68f9

File tree

9 files changed

+271
-39
lines changed

9 files changed

+271
-39
lines changed

src/collectors.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,3 +351,45 @@ def block_height(self):
351351
def latency(self):
352352
"""Returns connection latency."""
353353
return self.interface.latest_query_latency
354+
355+
356+
class AptosCollector():
357+
"""A collector to fetch information about Aptos endpoints."""
358+
359+
def __init__(self, url, labels, chain_id, **client_parameters):
360+
361+
self.labels = labels
362+
self.chain_id = chain_id
363+
self.interface = HttpsInterface(url, client_parameters.get('open_timeout'),
364+
client_parameters.get('ping_timeout'))
365+
366+
self._logger_metadata = {
367+
'component': 'AptosCollector',
368+
'url': strip_url(url)
369+
}
370+
371+
def alive(self):
372+
"""Returns true if endpoint is alive, false if not."""
373+
# Run cached query because we can also fetch client version from this
374+
# later on. This will save us an RPC call per run.
375+
return self.interface.cached_json_rest_api_get() is not None
376+
377+
def block_height(self):
378+
"""Runs a cached query to return block height"""
379+
blockchain_info = self.interface.cached_json_rest_api_get()
380+
return validate_dict_and_return_key_value(
381+
blockchain_info, 'block_height', self._logger_metadata, to_number=True)
382+
383+
def client_version(self):
384+
"""Runs a cached query to return client version."""
385+
blockchain_info = self.interface.cached_json_rest_api_get()
386+
version = validate_dict_and_return_key_value(
387+
blockchain_info, 'git_hash', self._logger_metadata, stringify=True)
388+
if version is None:
389+
return None
390+
client_version = {"client_version": version}
391+
return client_version
392+
393+
def latency(self):
394+
"""Returns connection latency."""
395+
return self.interface.latest_query_latency

src/configuration.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def endpoints(self):
4747
def _load_configuration(self):
4848
allowed_providers = self._load_validation_file()
4949
supported_collectors = ('evm', 'cardano', 'conflux', 'solana',
50-
'bitcoin', 'doge', 'filecoin', 'starknet')
50+
'bitcoin', 'doge', 'filecoin', 'starknet', 'aptos')
5151

5252
configuration_schema = Schema({
5353
'blockchain':

src/helpers.py

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
"""Module for providing useful functions accessible globally."""
22

33
import urllib.parse
4+
import json
45
from json.decoder import JSONDecodeError
56
from jsonrpcclient import Ok, parse_json
6-
77
from log import logger
88

99

@@ -13,34 +13,50 @@ def strip_url(url) -> str:
1313
return urllib.parse.urlparse(url).hostname
1414

1515

16-
def return_and_validate_rpc_json_result(message: str, logger_metadata) -> dict:
16+
def return_and_validate_json_result(message: str, json_type: str,logger_metadata) -> dict:
1717
"""Loads json rpc response text and validates the response
18-
as per JSON-RPC 2.0 Specification. In case the message is
18+
as per JSON-RPC 2.0 Specification or JSON parsable if type is REST. In case the message is
1919
not valid it returns None. This method is used by both HTTPS and
2020
Websocket Interface."""
2121
try:
22-
parsed = parse_json(message)
23-
if isinstance(parsed, Ok): # pylint: disable=no-else-return
24-
return parsed.result
22+
if json_type=='RPC':
23+
parsed = parse_json(message)
24+
if isinstance(parsed, Ok): # pylint: disable=no-else-return
25+
return parsed.result
26+
else:
27+
logger.error('Error in RPC message.',
28+
message=message, **logger_metadata)
2529
else:
26-
logger.error('Error in RPC message.',
27-
message=message, **logger_metadata)
30+
parsed = json.loads(message)
31+
return parsed
2832
except (JSONDecodeError, KeyError) as error:
2933
logger.error('Invalid JSON RPC object in RPC message.',
3034
message=message,
3135
error=error,
3236
**logger_metadata)
3337
return None
3438

39+
def return_and_validate_rpc_json_result(message: str, logger_metadata) -> dict:
40+
"""Validate that message is JSON parsable and per JSON-RPC specs"""
41+
return return_and_validate_json_result(message,json_type='RPC',logger_metadata=logger_metadata)
42+
43+
def return_and_validate_rest_api_json_result(message: str, logger_metadata) -> dict:
44+
"""Validate that message is JSON parsable"""
45+
return return_and_validate_json_result(message,json_type='REST',logger_metadata=logger_metadata)
3546

36-
def validate_dict_and_return_key_value(data, key, logger_metadata, stringify=False):
47+
def validate_dict_and_return_key_value(data, key, logger_metadata, stringify=False, to_number=False): # pylint: disable=line-too-long
3748
"""Validates that a dict is provided and returns the key value either in
3849
original form or as a string"""
3950
if isinstance(data, dict):
4051
value = data.get(key)
4152
if value is not None:
4253
if stringify:
4354
return str(value)
55+
if to_number:
56+
try:
57+
return float(value)
58+
except ValueError:
59+
return None
4460
return value
4561
logger.error("Provided data is not a dict or has no value for key",
4662
key=key,

src/interfaces.py

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import requests
1010
from urllib3 import Timeout
1111

12-
from helpers import strip_url, return_and_validate_rpc_json_result
12+
from helpers import strip_url, return_and_validate_rpc_json_result, return_and_validate_rest_api_json_result # pylint: disable=line-too-long
1313
from cache import Cache
1414
from log import logger
1515

@@ -37,37 +37,43 @@ def latest_query_latency(self):
3737
self._latest_query_latency = None
3838
return latency
3939

40-
def _return_and_validate_post_request(self, payload: dict) -> str:
41-
"""Sends a POST request and validates the http response code. If
42-
response code is OK, it returns the response.text, otherwise
43-
it returns None.
44-
"""
40+
def _return_and_validate_request(self, method='GET', payload=None, params=None):
41+
"""Sends a GET or POST request and validates the http response code."""
4542
with self.session as ses:
4643
try:
47-
self._logger.debug("Querying endpoint.",
48-
payload=payload,
49-
**self._logger_metadata)
44+
self._logger.debug(f"Querying endpoint with {method}.",
45+
payload=payload,
46+
params=params,
47+
**self._logger_metadata)
5048
start_time = perf_counter()
51-
req = ses.post(self.url,
52-
json=payload,
53-
timeout=Timeout(connect=self.connect_timeout,
54-
read=self.response_timeout))
55-
if req.status_code == requests.codes.ok: # pylint: disable=no-member
49+
if method.upper() == 'GET':
50+
req = ses.get(self.url,
51+
params=params,
52+
timeout=Timeout(connect=self.connect_timeout,
53+
read=self.response_timeout))
54+
elif method.upper() == 'POST':
55+
req = ses.post(self.url,
56+
json=payload,
57+
timeout=Timeout(connect=self.connect_timeout,
58+
read=self.response_timeout))
59+
else:
60+
raise ValueError(f"Unsupported HTTP method: {method}")
61+
62+
if req.status_code == requests.codes.ok: # pylint: disable=no-member
5663
self._latest_query_latency = perf_counter() - start_time
5764
return req.text
58-
except (IOError, requests.HTTPError,
59-
json.decoder.JSONDecodeError) as error:
60-
self._logger.error("Problem while sending a post request.",
61-
payload=payload,
62-
error=error,
63-
**self._logger_metadata)
64-
return None
65+
except (IOError, requests.HTTPError, json.decoder.JSONDecodeError, ValueError) as error:
66+
self._logger.error(f"Problem while sending a {method} request.",
67+
payload=payload,
68+
params=params,
69+
error=error,
70+
**self._logger_metadata)
6571
return None
6672

6773
def json_rpc_post(self, payload):
6874
"""Checks the validity of a successful json-rpc response. If any of the
6975
validations fail, the method returns type None. """
70-
response = self._return_and_validate_post_request(payload)
76+
response = self._return_and_validate_request(method='POST', payload=payload)
7177
if response is not None:
7278
result = return_and_validate_rpc_json_result(
7379
response, self._logger_metadata)
@@ -76,20 +82,41 @@ def json_rpc_post(self, payload):
7682
return None
7783

7884
def cached_json_rpc_post(self, payload: dict):
79-
"""Calls json_rpc_post and stores the result in in-memory
80-
cache, by using payload as key.Method will always return
81-
cached value after the first call. Cache never expires."""
82-
cache_key = str(payload)
85+
"""Calls json_rpc_post and stores the result in in-memory cache."""
86+
cache_key = f"rpc:{str(payload)}"
8387

8488
if self.cache.is_cached(cache_key):
8589
return_value = self.cache.retrieve_key_value(cache_key)
8690
return return_value
8791

88-
value = self.json_rpc_post(payload)
92+
value = self.json_rpc_post(payload=payload)
8993
if value is not None:
9094
self.cache.store_key_value(cache_key, value)
9195
return value
9296

97+
def json_rest_api_get(self, params: dict = None):
98+
"""Checks the validity of a successful json-rpc response. If any of the
99+
validations fail, the method returns type None. """
100+
response = self._return_and_validate_request(method='GET', params=params)
101+
if response is not None:
102+
result = return_and_validate_rest_api_json_result(
103+
response, self._logger_metadata)
104+
if result is not None:
105+
return result
106+
return None
107+
108+
def cached_json_rest_api_get(self, params: dict = None):
109+
"""Calls json_rest_api_get and stores the result in in-memory cache."""
110+
cache_key = f"rest:{str(params)}"
111+
112+
if self.cache.is_cached(cache_key):
113+
return_value = self.cache.retrieve_key_value(cache_key)
114+
return return_value
115+
116+
value = self.json_rest_api_get(params)
117+
if value is not None:
118+
self.cache.store_key_value(cache_key, value)
119+
return value
93120

94121
class WebsocketSubscription(threading.Thread): # pylint: disable=too-many-instance-attributes
95122
"""A thread class used to subscribe and track

src/registries.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ def get_collector_registry(self) -> list:
8181
collector = collectors.SolanaCollector
8282
case "starknet", "starknet":
8383
collector = collectors.StarknetCollector
84+
case "aptos", "aptos":
85+
collector = collectors.AptosCollector
8486
case "evm", other: # pylint: disable=unused-variable
8587
collector = collectors.EvmCollector
8688
if collector is None:

src/test_collectors.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,3 +650,87 @@ def test_latency(self):
650650
"""Tests that the latency is obtained from the interface based on latest_query_latency"""
651651
self.mocked_connection.return_value.latest_query_latency = 0.123
652652
self.assertEqual(0.123, self.starknet_collector.latency())
653+
654+
class TestAptosCollector(TestCase):
655+
"""Tests the Aptos collector class"""
656+
657+
def setUp(self):
658+
self.url = "https://test.com"
659+
self.labels = ["dummy", "labels"]
660+
self.chain_id = 123
661+
self.open_timeout = 8
662+
self.ping_timeout = 9
663+
self.client_params = {
664+
"open_timeout": self.open_timeout, "ping_timeout": self.ping_timeout}
665+
with mock.patch('collectors.HttpsInterface') as mocked_connection:
666+
self.aptos_collector = collectors.AptosCollector(
667+
self.url, self.labels, self.chain_id, **self.client_params)
668+
self.mocked_connection = mocked_connection
669+
670+
def test_logger_metadata(self):
671+
"""Validate logger metadata. Makes sure url is stripped by helpers.strip_url function."""
672+
expected_metadata = {
673+
'component': 'AptosCollector', 'url': 'test.com'}
674+
self.assertEqual(expected_metadata,
675+
self.aptos_collector._logger_metadata)
676+
677+
def test_https_interface_created(self):
678+
"""Tests that the Aptos collector calls the https interface with the correct args"""
679+
self.mocked_connection.assert_called_once_with(
680+
self.url, self.open_timeout, self.ping_timeout)
681+
682+
def test_interface_attribute_exists(self):
683+
"""Tests that the interface attribute exists."""
684+
self.assertTrue(hasattr(self.aptos_collector, 'interface'))
685+
686+
def test_alive_call(self):
687+
"""Tests the alive function uses the correct call"""
688+
self.aptos_collector.alive()
689+
self.mocked_connection.return_value.cached_json_rest_api_get.assert_called_once()
690+
691+
def test_alive_false(self):
692+
"""Tests the alive function returns false when get returns None"""
693+
self.mocked_connection.return_value.cached_json_rest_api_get.return_value = None
694+
result = self.aptos_collector.alive()
695+
self.assertFalse(result)
696+
697+
def test_block_height(self):
698+
"""Tests the block_height function uses the correct call to get block height"""
699+
self.aptos_collector.block_height()
700+
self.mocked_connection.return_value.cached_json_rest_api_get.assert_called_once()
701+
702+
def test_block_height_returns_none(self):
703+
"""Tests that the block height returns None if cached_json_rest_api_get returns None"""
704+
self.mocked_connection.return_value.cached_json_rest_api_get.return_value = None
705+
result = self.aptos_collector.block_height()
706+
self.assertIsNone(result)
707+
708+
def test_client_version(self):
709+
"""Tests the client_version function uses the correct call to get client version"""
710+
self.aptos_collector.client_version()
711+
self.mocked_connection.return_value.cached_json_rest_api_get.assert_called_once()
712+
713+
def test_client_version_get_git_hash(self):
714+
"""Tests that the client version is returned as a string with the git_hash key"""
715+
self.mocked_connection.return_value.cached_json_rest_api_get.return_value = {
716+
"git_hash": "abcdef123"}
717+
result = self.aptos_collector.client_version()
718+
self.assertEqual({"client_version": "abcdef123"}, result)
719+
720+
def test_client_version_key_error_returns_none(self):
721+
"""Tests that the client_version returns None on KeyError"""
722+
self.mocked_connection.return_value.cached_json_rest_api_get.return_value = {
723+
"dummy_key": "value"}
724+
result = self.aptos_collector.client_version()
725+
self.assertIsNone(result)
726+
727+
def test_client_version_returns_none(self):
728+
"""Tests that the client_version returns None if cached_json_rest_api_get returns None"""
729+
self.mocked_connection.return_value.cached_json_rest_api_get.return_value = None
730+
result = self.aptos_collector.client_version()
731+
self.assertIsNone(result)
732+
733+
def test_latency(self):
734+
"""Tests that the latency is obtained from the interface based on latest_query_latency"""
735+
self.mocked_connection.return_value.latest_query_latency = 0.123
736+
self.assertEqual(0.123, self.aptos_collector.latency())

0 commit comments

Comments
 (0)