From b8e678acc234a86998013846059ecc1438cc7d32 Mon Sep 17 00:00:00 2001
From: Andrea Lamparelli <a.lamparelli95@gmail.com>
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