From b8e678acc234a86998013846059ecc1438cc7d32 Mon Sep 17 00:00:00 2001 From: Andrea Lamparelli Date: Wed, 24 Apr 2024 09:37:21 +0200 Subject: [PATCH] Make inner HTTP client configurable --- Makefile | 11 ++ docs/GET_STARTED.md | 6 +- examples/basic_example.py | 134 ++++++++++++++++++ examples/data/acme_benchmark_schema.json | 7 + examples/data/acme_horreum_schema.json | 7 + examples/data/acme_transformer.json | 8 ++ .../data/acme_transformer_extractors.json | 12 ++ examples/data/roadrunner_run.json | 7 + examples/data/roadrunner_run_data.json | 17 +++ examples/data/roadrunner_test.json | 7 + examples/read_only_example.py | 42 ++++++ src/horreum/__init__.py | 5 +- src/horreum/configs.py | 21 +++ src/horreum/horreum_client.py | 60 +++++--- test/horreum_client_it.py | 38 +++-- 15 files changed, 350 insertions(+), 32 deletions(-) create mode 100644 examples/basic_example.py create mode 100644 examples/data/acme_benchmark_schema.json create mode 100644 examples/data/acme_horreum_schema.json create mode 100644 examples/data/acme_transformer.json create mode 100644 examples/data/acme_transformer_extractors.json create mode 100644 examples/data/roadrunner_run.json create mode 100644 examples/data/roadrunner_run_data.json create mode 100644 examples/data/roadrunner_test.json create mode 100644 examples/read_only_example.py create mode 100644 src/horreum/configs.py diff --git a/Makefile b/Makefile index 0e968ee..eff403e 100644 --- a/Makefile +++ b/Makefile @@ -78,3 +78,14 @@ generate: tools ${OPENAPI_SPEC} ## Generate the Horreum client set -e ;\ ${PROJECT_BIN}/kiota generate -l python -c HorreumRawClient -n raw_client -d ${OPENAPI_PATH}/openapi.yaml -o ${GENERATED_CLIENT_PATH} ;\ } + + +##@ Example + +.PHONY: run-basic-example +run-basic-example: ## Run basic example + cd examples && python basic_example.py + +.PHONY: run-read-only-example +run-read-only-example: ## Run read-only example + cd examples && python read_only_example.py diff --git a/docs/GET_STARTED.md b/docs/GET_STARTED.md index 3cd1d65..0ae130b 100644 --- a/docs/GET_STARTED.md +++ b/docs/GET_STARTED.md @@ -57,10 +57,10 @@ Here a very simple example: >>> import asyncio # Import the constructor function ->>> from horreum.horreum_client import new_horreum_client +>>> from horreum.horreum_client import new_horreum_client, HorreumCredentials # Initialize the client ->>> client = await new_horreum_client(base_url="http://localhost:8080", username="..", password="..") +>>> client = await new_horreum_client(base_url="http://localhost:8080", credentials=HorreumCredentials(username=username, password=password)) # Call the api using the underlying raw client, in this case retrieve the Horreum server version >>> await client.raw_client.api.config.version.get() @@ -72,7 +72,7 @@ The previous api call is equivalent to the following `cURL`: curl --silent -X 'GET' 'http://localhost:8080/api/config/version' -H 'accept: application/json' | jq '.' ``` -Other examples can be found in the [test folder](../test), for instance: +Other examples can be found in the [examples folder](../examples), for instance: ```bash # Import Horreum Test model diff --git a/examples/basic_example.py b/examples/basic_example.py new file mode 100644 index 0000000..ee7792c --- /dev/null +++ b/examples/basic_example.py @@ -0,0 +1,134 @@ +import asyncio +import json + +from kiota_abstractions.base_request_configuration import RequestConfiguration + +from horreum import HorreumCredentials, new_horreum_client +from horreum.horreum_client import HorreumClient +from horreum.raw_client.api.run.test.test_request_builder import TestRequestBuilder +from horreum.raw_client.models.extractor import Extractor +from horreum.raw_client.models.run import Run +from horreum.raw_client.models.schema import Schema +from horreum.raw_client.models.test import Test +from horreum.raw_client.models.transformer import Transformer + +base_url = "http://localhost:8080" +username = "user" +password = "secret" + +cleanup_data = True + + +async def create_schema(client: HorreumClient, data_path: str) -> int: + print(f"creating schema from {data_path}") + schema_data = json.load(open(data_path), object_hook=lambda d: Schema(**d)) + print(schema_data) + + schema_id = await client.raw_client.api.schema.post(schema_data) + assert schema_id > 0 + return schema_id + + +async def create_schema_transformers(client: HorreumClient, schema_id: int, data_path: str, + extractors_data_path: str) -> int: + print(f"creating transformer from {data_path}") + transformer_data = json.load(open(data_path), object_hook=lambda d: Transformer(**d)) + print(transformer_data) + + print(f"creating extractors from {extractors_data_path}") + extractors_data = json.load(open(extractors_data_path), + object_hook=lambda d: Extractor(**d)) + print(extractors_data) + + transformer_data.extractors = extractors_data + + transformer_id = await client.raw_client.api.schema.by_id_id(schema_id).transformers.post(transformer_data) + assert transformer_id > 0 + return transformer_id + + +async def create_test(client: HorreumClient, data_path: str) -> Test: + print(f"creating test from {data_path}") + + test_data = json.load(open(data_path), object_hook=lambda d: Test(**d)) + print(test_data) + + test = await client.raw_client.api.test.post(test_data) + assert test.id > 0 + return test + + +async def set_test_transformers(client: HorreumClient, test_id: int, transformers: list[int]): + await client.raw_client.api.test.by_id(test_id).transformers.post(transformers) + + +async def upload_run(client: HorreumClient, test_id: int, run_path: str, run_data_path: str): + print(f"uploading run from {run_path}") + + run = json.load(open(run_path), object_hook=lambda d: Run(**d)) + run_data = json.load(open(run_data_path)) + run.data = json.dumps(run_data) + print(run) + + query_params = TestRequestBuilder.TestRequestBuilderPostQueryParameters(test=str(test_id)) + config = RequestConfiguration(query_parameters=query_params) + await client.raw_client.api.run.test.post(run, config) + + +async def setup_roadrunner_test(client: HorreumClient): + print("creating roadrunner test") + + acme_benchmark_schema_id = await create_schema(client, "./data/acme_benchmark_schema.json") + acme_horreum_schema_id = await create_schema(client, "./data/acme_horreum_schema.json") + + acme_transformers_id = await create_schema_transformers(client, acme_benchmark_schema_id, + "./data/acme_transformer.json", + "./data/acme_transformer_extractors.json") + + roadrunner_test = await create_test(client, "./data/roadrunner_test.json") + await set_test_transformers(client, roadrunner_test.id, [acme_transformers_id]) + + await upload_run(client, roadrunner_test.id, "./data/roadrunner_run.json", "./data/roadrunner_run_data.json") + + +async def delete_all(client: HorreumClient): + """ cleanup all Horreum data """ + + print("cleaning up tests") + get_tests = await client.raw_client.api.test.get() + for t in get_tests.tests: + await client.raw_client.api.test.by_id(t.id).delete() + + get_tests = await client.raw_client.api.test.get() + assert get_tests.count == 0 + + print("cleaning up schemas") + get_schemas = await client.raw_client.api.schema.get() + for s in get_schemas.schemas: + await client.raw_client.api.schema.by_id_id(s.id).delete() + + get_schemas = await client.raw_client.api.schema.get() + assert get_schemas.count == 0 + + +async def example(): + client = await new_horreum_client(base_url, credentials=HorreumCredentials(username=username, password=password)) + + if cleanup_data: + await delete_all(client) + + await setup_roadrunner_test(client) + + # check data is properly injected in the server + get_schemas = await client.raw_client.api.schema.get() + assert get_schemas.count == 2 + + get_tests = await client.raw_client.api.test.get() + assert get_tests.count == 1 + + get_runs = await client.raw_client.api.run.list_.get() + assert get_runs.total == 1 + + +if __name__ == '__main__': + asyncio.run(example()) diff --git a/examples/data/acme_benchmark_schema.json b/examples/data/acme_benchmark_schema.json new file mode 100644 index 0000000..d7e4751 --- /dev/null +++ b/examples/data/acme_benchmark_schema.json @@ -0,0 +1,7 @@ +{ + "name": "ACME Benchmark Schema", + "description": "Data produced by benchmarking tool", + "owner": "dev-team", + "access": "PUBLIC", + "uri": "urn:acme:benchmark:0.1" +} \ No newline at end of file diff --git a/examples/data/acme_horreum_schema.json b/examples/data/acme_horreum_schema.json new file mode 100644 index 0000000..b7b9aca --- /dev/null +++ b/examples/data/acme_horreum_schema.json @@ -0,0 +1,7 @@ +{ + "name": "ACME Horreum Schema", + "description": "Used in Datasets", + "owner": "dev-team", + "access": "PUBLIC", + "uri": "urn:acme:horreum:0.1" +} \ No newline at end of file diff --git a/examples/data/acme_transformer.json b/examples/data/acme_transformer.json new file mode 100644 index 0000000..6091761 --- /dev/null +++ b/examples/data/acme_transformer.json @@ -0,0 +1,8 @@ +{ + "name": "Acme Transformer", + "description": "Transformer for converting complex runs into individual datasets", + "owner": "dev-team", + "access": "PUBLIC", + "target_schema_uri": "urn:acme:horreum:0.1", + "function": "({results, hash}) => results.map(r => ({ ...r, hash }))" +} \ No newline at end of file diff --git a/examples/data/acme_transformer_extractors.json b/examples/data/acme_transformer_extractors.json new file mode 100644 index 0000000..e7acf2b --- /dev/null +++ b/examples/data/acme_transformer_extractors.json @@ -0,0 +1,12 @@ +[ + { + "name": "hash", + "jsonpath": "$.buildHash", + "isarray": false + }, + { + "name": "results", + "jsonpath": "$.results", + "isarray": false + } +] \ No newline at end of file diff --git a/examples/data/roadrunner_run.json b/examples/data/roadrunner_run.json new file mode 100644 index 0000000..97eb531 --- /dev/null +++ b/examples/data/roadrunner_run.json @@ -0,0 +1,7 @@ +{ + "description": "Example run of Roadrunner", + "owner": "dev-team", + "access": "PUBLIC", + "start": 1669388931000, + "stop": 1669388932000 +} \ No newline at end of file diff --git a/examples/data/roadrunner_run_data.json b/examples/data/roadrunner_run_data.json new file mode 100644 index 0000000..a109d68 --- /dev/null +++ b/examples/data/roadrunner_run_data.json @@ -0,0 +1,17 @@ +{ + "$schema": "urn:acme:benchmark:0.1", + "something": "This gets lost by the transformer", + "buildHash": "defec8eddeadbeafcafebabeb16b00b5", + "results": [ + { + "test": "Foo", + "requests": 123, + "duration": 10 + }, + { + "test": "Bar", + "requests": 456, + "duration": 20 + } + ] +} \ No newline at end of file diff --git a/examples/data/roadrunner_test.json b/examples/data/roadrunner_test.json new file mode 100644 index 0000000..af3d8b7 --- /dev/null +++ b/examples/data/roadrunner_test.json @@ -0,0 +1,7 @@ +{ + "name": "Roadrunner Test", + "description": "acme.com benchmark", + "owner": "dev-team", + "access": "PUBLIC", + "fingerprint_labels": [ "benchmark_test" ] +} \ No newline at end of file diff --git a/examples/read_only_example.py b/examples/read_only_example.py new file mode 100644 index 0000000..f0b626c --- /dev/null +++ b/examples/read_only_example.py @@ -0,0 +1,42 @@ +import asyncio + +import httpx + +from horreum import new_horreum_client, ClientConfiguration + +DEFAULT_CONNECTION_TIMEOUT: int = 30 +DEFAULT_REQUEST_TIMEOUT: int = 100 + +base_url = "http://localhost:8080" +username = "user" +password = "secret" + +expected_server_version = "0.13.0" +expected_n_schemas = 2 +expected_n_tests = 1 +enable_assertions = False + + +async def example(): + timeout = httpx.Timeout(DEFAULT_REQUEST_TIMEOUT, connect=DEFAULT_CONNECTION_TIMEOUT) + http_client = httpx.AsyncClient(timeout=timeout, http2=True, verify=False) + client = await new_horreum_client(base_url, client_config=ClientConfiguration(http_client=http_client)) + + server_version = await client.raw_client.api.config.version.get() + print(server_version) + if enable_assertions: + assert server_version.version == expected_server_version + + get_schemas = await client.raw_client.api.schema.get() + print(get_schemas.count) + if enable_assertions: + assert get_schemas.count == expected_n_schemas + + get_tests = await client.raw_client.api.test.get() + print(get_tests.count) + if enable_assertions: + assert get_tests.count == expected_n_tests + + +if __name__ == '__main__': + asyncio.run(example()) diff --git a/src/horreum/__init__.py b/src/horreum/__init__.py index f296a0e..0afa1be 100644 --- a/src/horreum/__init__.py +++ b/src/horreum/__init__.py @@ -1,5 +1,8 @@ +from horreum.configs import HorreumCredentials, ClientConfiguration from horreum.horreum_client import new_horreum_client __all__ = [ - new_horreum_client + new_horreum_client, + HorreumCredentials, + ClientConfiguration ] diff --git a/src/horreum/configs.py b/src/horreum/configs.py new file mode 100644 index 0000000..775b05b --- /dev/null +++ b/src/horreum/configs.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass +from typing import Optional + +import httpx +from kiota_abstractions.request_option import RequestOption + + +@dataclass(frozen=True) +class HorreumCredentials: + username: str = None + password: str = None + + +@dataclass +class ClientConfiguration: + # inner http async client that will be used to perform raw requests + http_client: Optional[httpx.AsyncClient] = None + # if true, set default middleware on the provided client + use_default_middlewares: bool = True + # if set use these options for default middlewares + options: Optional[dict[str, RequestOption]] = None diff --git a/src/horreum/horreum_client.py b/src/horreum/horreum_client.py index 41d1a3d..46432e5 100644 --- a/src/horreum/horreum_client.py +++ b/src/horreum/horreum_client.py @@ -1,15 +1,22 @@ from importlib.metadata import version +from typing import Optional +import httpx from kiota_abstractions.authentication import AuthenticationProvider from kiota_abstractions.authentication.access_token_provider import AccessTokenProvider from kiota_abstractions.authentication.anonymous_authentication_provider import AnonymousAuthenticationProvider from kiota_abstractions.authentication.base_bearer_token_authentication_provider import ( BaseBearerTokenAuthenticationProvider) from kiota_http.httpx_request_adapter import HttpxRequestAdapter +from kiota_http.kiota_client_factory import KiotaClientFactory +from .configs import HorreumCredentials, ClientConfiguration from .keycloak_access_provider import KeycloakAccessProvider from .raw_client.horreum_raw_client import HorreumRawClient +DEFAULT_CONNECTION_TIMEOUT: int = 30 +DEFAULT_REQUEST_TIMEOUT: int = 100 + async def setup_auth_provider(base_url: str, username: str, password: str) -> AccessTokenProvider: # Use not authenticated client to fetch the auth mechanism @@ -25,34 +32,48 @@ async def setup_auth_provider(base_url: str, username: str, password: str) -> Ac class HorreumClient: __base_url: str - __username: str - __password: str + __credentials: Optional[HorreumCredentials] + __client_config: Optional[ClientConfiguration] + __http_client: httpx.AsyncClient # Raw client, this could be used to interact with the low-level api raw_client: HorreumRawClient - auth_provider: AuthenticationProvider + # By default, set as anonymous authentication + auth_provider: AuthenticationProvider = AnonymousAuthenticationProvider() - def __init__(self, base_url: str, username: str = None, password: str = None): + def __init__(self, base_url: str, credentials: Optional[HorreumCredentials], + client_config: Optional[ClientConfiguration]): self.__base_url = base_url - self.__username = username - self.__password = password + self.__credentials = credentials + self.__client_config = client_config + + if client_config and client_config.http_client and client_config.use_default_middlewares: + self.__http_client = KiotaClientFactory.create_with_default_middleware(client=client_config.http_client, + options=client_config.options) + else: + self.__http_client = client_config.http_client if client_config else None async def setup(self): """ Set up the authentication provider, based on the Horreum configuration, and the low-level horreum api client """ - if self.__username is not None: - # Bearer token authentication - access_provider = await setup_auth_provider(self.__base_url, self.__username, self.__password) - self.auth_provider = BaseBearerTokenAuthenticationProvider(access_provider) - elif self.__password is not None: - raise RuntimeError("providing password without username, have you missed something?") + if self.__credentials: + if self.__credentials.username is not None: + # Bearer token authentication + access_provider = await setup_auth_provider(self.__base_url, self.__credentials.username, + self.__credentials.password) + self.auth_provider = BaseBearerTokenAuthenticationProvider(access_provider) + elif self.__credentials.password is not None: + raise RuntimeError("providing password without username, have you missed something?") + + if self.__http_client: + req_adapter = HttpxRequestAdapter(authentication_provider=self.auth_provider, + http_client=self.__http_client) else: - # Anonymous authentication - self.auth_provider = AnonymousAuthenticationProvider() + # rely on the Kiota default is not provided by user + req_adapter = HttpxRequestAdapter(authentication_provider=self.auth_provider) - req_adapter = HttpxRequestAdapter(self.auth_provider) req_adapter.base_url = self.__base_url self.raw_client = HorreumRawClient(req_adapter) @@ -66,15 +87,16 @@ def version() -> str: return version("horreum") -async def new_horreum_client(base_url: str, username: str = None, password: str = None) -> HorreumClient: +async def new_horreum_client(base_url: str, credentials: Optional[HorreumCredentials] = None, + client_config: Optional[ClientConfiguration] = None) -> HorreumClient: """ Initialize the horreum client :param base_url: horreum api base url - :param username: auth username - :param password: auth password + :param credentials: horreum credentials in the form of username and pwd + :param client_config: inner http client configuration :return: HorreumClient instance """ - client = HorreumClient(base_url, username, password) + client = HorreumClient(base_url, credentials=credentials, client_config=client_config) await client.setup() return client diff --git a/test/horreum_client_it.py b/test/horreum_client_it.py index 2423b6b..8dc81aa 100644 --- a/test/horreum_client_it.py +++ b/test/horreum_client_it.py @@ -6,13 +6,17 @@ from kiota_abstractions.method import Method from kiota_abstractions.request_information import RequestInformation +from horreum import HorreumCredentials, ClientConfiguration from horreum.horreum_client import new_horreum_client, HorreumClient from horreum.raw_client.api.test.test_request_builder import TestRequestBuilder from horreum.raw_client.models.protected_type_access import ProtectedType_access from horreum.raw_client.models.test import Test -username = "user" -password = "secret" +DEFAULT_CONNECTION_TIMEOUT: int = 30 +DEFAULT_REQUEST_TIMEOUT: int = 100 + +USERNAME = "user" +PASSWORD = "secret" @pytest.fixture() @@ -35,7 +39,23 @@ async def anonymous_client() -> HorreumClient: @pytest.fixture() async def authenticated_client() -> HorreumClient: print("Setting up authenticated client") - client = await new_horreum_client(base_url="http://localhost:8080", username=username, password=password) + client = await new_horreum_client(base_url="http://localhost:8080", + credentials=HorreumCredentials(username=USERNAME, password=PASSWORD)) + try: + await client.raw_client.api.config.version.get() + except httpx.ConnectError: + pytest.fail("Unable to fetch Horreum version, is Horreum running in the background?") + return client + + +@pytest.fixture() +async def custom_authenticated_client() -> HorreumClient: + print("Setting up custom authenticated client") + timeout = httpx.Timeout(DEFAULT_REQUEST_TIMEOUT, connect=DEFAULT_CONNECTION_TIMEOUT) + client = await new_horreum_client(base_url="http://localhost:8080", + credentials=HorreumCredentials(username=USERNAME, password=PASSWORD), + client_config=ClientConfiguration( + http_client=httpx.AsyncClient(timeout=timeout, http2=True, verify=False))) try: await client.raw_client.api.config.version.get() except httpx.ConnectError: @@ -68,7 +88,7 @@ async def test_check_auth_token(authenticated_client: HorreumClient): @pytest.mark.asyncio async def test_missing_username_with_password(): with pytest.raises(RuntimeError) as ex: - await new_horreum_client(base_url="http://localhost:8080", password=password) + await new_horreum_client(base_url="http://localhost:8080", credentials=HorreumCredentials(password=PASSWORD)) assert str(ex.value) == "providing password without username, have you missed something?" @@ -80,14 +100,14 @@ async def test_check_no_tests(authenticated_client: HorreumClient): @pytest.mark.asyncio -async def test_check_create_test(authenticated_client: HorreumClient): +async def test_check_create_test(custom_authenticated_client: HorreumClient): # Create new test t = Test(name="TestName", description="Simple test", owner="dev-team", access=ProtectedType_access.PUBLIC) - created = await authenticated_client.raw_client.api.test.post(t) + created = await custom_authenticated_client.raw_client.api.test.post(t) assert created is not None - assert (await authenticated_client.raw_client.api.test.get()).count == 1 + assert (await custom_authenticated_client.raw_client.api.test.get()).count == 1 # TODO: we could automate setup/teardown process # Delete test - await authenticated_client.raw_client.api.test.by_id(created.id).delete() - assert (await authenticated_client.raw_client.api.test.get()).count == 0 + await custom_authenticated_client.raw_client.api.test.by_id(created.id).delete() + assert (await custom_authenticated_client.raw_client.api.test.get()).count == 0