diff --git a/Makefile b/Makefile index 1f69f0f..14152b8 100644 --- a/Makefile +++ b/Makefile @@ -82,6 +82,10 @@ generate: tools ${OPENAPI_SPEC} ## Generate the Horreum client ##@ Example -.PHONY: run-example -run-example: ## Run basic 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 index 1102a56..ee7792c 100644 --- a/examples/basic_example.py +++ b/examples/basic_example.py @@ -3,14 +3,14 @@ from kiota_abstractions.base_request_configuration import RequestConfiguration -from horreum import new_horreum_client +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 -from horreum.raw_client.api.run.test.test_request_builder import TestRequestBuilder base_url = "http://localhost:8080" username = "user" @@ -112,7 +112,7 @@ async def delete_all(client: HorreumClient): async def example(): - client = await new_horreum_client(base_url, username, password) + client = await new_horreum_client(base_url, credentials=HorreumCredentials(username=username, password=password)) if cleanup_data: await delete_all(client) 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