Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions pyoaev/client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union
from urllib import parse
from uuid import UUID

import requests

Expand All @@ -23,6 +24,7 @@ def __init__(
pagination: Optional[str] = None,
order_by: Optional[str] = None,
ssl_verify: Union[bool, str] = True,
tenant_id: Optional[UUID] = None,
**kwargs: Any,
) -> None:

Expand All @@ -32,6 +34,7 @@ def __init__(
raise ValueError("A TOKEN must be set")

self.url = url
self.tenant_id = tenant_id
self.timeout = timeout
#: Headers that will be used in request to OpenAEV
self.headers = {
Expand Down Expand Up @@ -109,9 +112,14 @@ def _build_url(self, path: str) -> str:
Returns:
The full URL
"""
if path.startswith("http://") or path.startswith("https://"):
if parse.urlparse(path).scheme in ("http", "https"):
return path
return f"{self.url}/api{path}"
base_url = self.url.rstrip("/")
normalized_path = path.lstrip("/")
if self.tenant_id:
return f"{base_url}/api/tenants/{self.tenant_id}/{normalized_path}"
else:
return f"{base_url}/api/{normalized_path}"

def _get_session_opts(self) -> Dict[str, Any]:
return {
Expand Down
6 changes: 6 additions & 0 deletions pyoaev/configuration/settings_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import timedelta
from pathlib import Path
from typing import Annotated, Literal
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field, HttpUrl, PlainSerializer
from pydantic_settings import (
Expand Down Expand Up @@ -99,6 +100,11 @@ class ConfigLoaderOAEV(BaseConfigModel):
token: str = Field(
description="The token for the OpenAEV platform.",
)
tenant_id: UUID | None = Field(
default=None,
description="Identifier of the tenant within the OpenAEV platform. Used in multi-tenant environments to scope "
"API requests and ensure data isolation between different tenants.",
)


class ConfigLoaderCollector(BaseConfigModel):
Expand Down
6 changes: 4 additions & 2 deletions pyoaev/daemons/base_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from abc import ABC, abstractmethod
from inspect import signature
from types import FunctionType
from uuid import UUID

from pyoaev.client import OpenAEV
from pyoaev.configuration import Configuration
Expand Down Expand Up @@ -37,6 +38,7 @@ def __init__(
self.api = api_client or BaseDaemon.__get_default_api_client(
url=self._configuration.get("openaev_url"),
token=self._configuration.get("openaev_token"),
tenant_id=self._configuration.get("openaev_tenant_id"),
)

# logging
Expand Down Expand Up @@ -131,8 +133,8 @@ def get_id(self):
)

@classmethod
def __get_default_api_client(cls, url, token):
return OpenAEV(url=url, token=token)
def __get_default_api_client(cls, url, token, tenant_id: UUID | None):
return OpenAEV(url=url, token=token, tenant_id=tenant_id)

@classmethod
def __get_default_logger(cls, log_level, name):
Expand Down
1 change: 1 addition & 0 deletions pyoaev/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,6 +322,7 @@ def __init__(self, config: OpenAEVConfigHelper, icon) -> None:
self.api = OpenAEV(
url=config.get_conf("openaev_url"),
token=config.get_conf("openaev_token"),
tenant_id=config.get_conf("openaev_tenant_id"),
)
# Get the mq configuration from api
self.config = {
Expand Down
11 changes: 9 additions & 2 deletions pyoaev/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ def __init__(self, api, config, logger, ping_type) -> None:
threading.Thread.__init__(self)
self.ping_type = ping_type
self.api = api
self.tenant_id = getattr(self.api, "tenant_id", None)
self.config = config
self.logger = logger
self.in_error = False
Expand All @@ -203,9 +204,15 @@ def ping(self) -> None:
self.exit_event.wait(40)

def run(self) -> None:
self.logger.info("Starting PingAlive thread")
self.logger.info(
"Starting PingAlive thread",
{"tenant_id": str(self.tenant_id) if self.tenant_id else None},
)
self.ping()

def stop(self) -> None:
self.logger.info("Preparing PingAlive for clean shutdown")
self.logger.info(
"Preparing PingAlive for clean shutdown",
{"tenant_id": str(self.tenant_id) if self.tenant_id else None},
)
self.exit_event.set()
12 changes: 12 additions & 0 deletions test/bdd/constraints/multi_tenant_api_routing_constraint.feature
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we could add a constraint 'tenant_id is not a valid uuid4' ?

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: URL normalization in OpenAEV client

Scenario Outline: URL normalization combines base_url and path correctly
Given an OpenAEV client with base_url "<base_url>"
When I build the URL for "<path>"
Then the resulting URL should be "<expected>"

Examples:
| base_url | path | expected |
| base_url | path | base_url/api/path |
| base_url/ | /path | base_url/api/path |
| base_url// | //path | base_url/api/path |
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Feature: Tenant ID handling in OpenAEV configuration

Scenario: tenant_id is not provided
Given a configuration without tenant_id
When the configuration is loaded
Then tenant_id should be None


Scenario: tenant_id is explicitly set to None
Given a configuration with tenant_id set to None
When the configuration is loaded
Then tenant_id should be None


Scenario Outline: tenant_id is invalid and should raise a validation error
Given a configuration with tenant_id "<tenant_id>" invalid
When the configuration is loaded
Then a validation error should be raised

Examples:
| tenant_id |
| ChangeMe |
| "" |
| 550-e29-41d-a71-446 |


Scenario: tenant_id is a valid UUID
Given a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"
When the configuration is loaded
Then tenant_id should be a valid UUID
63 changes: 63 additions & 0 deletions test/bdd/constraints/test_multi_tenant_api_routing_constraint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""URL normalization in OpenAEV client feature tests."""

import pytest
from pytest_bdd import given, parsers, scenario, then, when

from pyoaev import OpenAEV

# --------------------------------------------------
# SCENARIOS
# --------------------------------------------------


@scenario(
"multi_tenant_api_routing_constraint.feature",
"URL normalization combines base_url and path correctly",
)
def test_url_normalization():
pass


# --------------------------------------------------
# FIXTURE CONTEXT
# --------------------------------------------------


@pytest.fixture
def context():
return {}


# --------------------------------------------------
# GIVEN
# --------------------------------------------------


@given(parsers.parse('an OpenAEV client with base_url "{base_url}"'))
def client(context, base_url):
context["client"] = OpenAEV(
url=base_url,
token="token",
tenant_id=None,
)


# --------------------------------------------------
# WHEN
# --------------------------------------------------


@when(parsers.parse('I build the URL for "{path}"'))
def build_url(context, path):
client = context["client"]
context["result"] = client._build_url(path)


# --------------------------------------------------
# THEN
# --------------------------------------------------


@then(parsers.parse('the resulting URL should be "{expected}"'))
def check_url(context, expected):
assert context["result"] == expected
132 changes: 132 additions & 0 deletions test/bdd/constraints/test_multi_tenant_validation_uuid_constraint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"""Tenant ID handling in OpenAEV configuration feature tests."""

from uuid import UUID

from pydantic import ValidationError
from pytest_bdd import given, parsers, scenario, then, when

from pyoaev.configuration.settings_loader import ConfigLoaderOAEV

# --------------------------------------------------
# SCENARIOS
# --------------------------------------------------


@scenario(
"multi_tenant_validation_uuid_constraint.feature",
"tenant_id is a valid UUID",
)
def test_tenant_id_is_a_valid_uuid():
pass


@scenario(
"multi_tenant_validation_uuid_constraint.feature",
"tenant_id is explicitly set to None",
)
def test_tenant_id_is_explicitly_set_to_none():
pass


@scenario(
"multi_tenant_validation_uuid_constraint.feature",
"tenant_id is invalid and should raise a validation error",
)
def test_tenant_id_is_invalid_and_should_raise_a_validation_error():
pass


@scenario(
"multi_tenant_validation_uuid_constraint.feature",
"tenant_id is not provided",
)
def test_tenant_id_is_not_provided():
pass


# --------------------------------------------------
# GIVEN
# --------------------------------------------------


@given(
"a configuration without tenant_id",
target_fixture="config",
)
def config_without():
return {
"url": "https://example.com",
"token": "token",
}


@given(
"a configuration with tenant_id set to None",
target_fixture="config",
)
def config_none():
return {
"url": "https://example.com",
"token": "token",
"tenant_id": None,
}


@given(
parsers.parse('a configuration with tenant_id "{tenant_id}" invalid'),
target_fixture="config",
)
def config_with_tenant_invalid(tenant_id):
return {
"url": "https://example.com",
"token": "token",
"tenant_id": tenant_id,
}


@given(
'a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"',
target_fixture="config",
)
def config_with_tenant_valid():
return {
"url": "https://example.com",
"token": "token",
"tenant_id": "2cffad3a-0001-4078-b0e2-ef74274022c3",
}


# --------------------------------------------------
# WHEN
# --------------------------------------------------


@when(
"the configuration is loaded",
target_fixture="result",
)
def load_config(config):
try:
return ConfigLoaderOAEV(**config)
except ValidationError as err:
return err


# --------------------------------------------------
# THEN
# --------------------------------------------------


@then("tenant_id should be None")
def assert_none(result):
assert result.tenant_id is None


@then("tenant_id should be a valid UUID")
def assert_uuid(result):
assert isinstance(result.tenant_id, UUID)


@then("a validation error should be raised")
def assert_validation_error(result):
assert isinstance(result, ValidationError)
17 changes: 17 additions & 0 deletions test/bdd/features/multi_tenant_api_routing.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Feature: Multi-tenant API routing in OpenAEV client

Scenario: Full URL bypasses tenant routing
Given an OpenAEV client with any tenant configuration
When I build the URL for "https://external.service/api/path"
Then the resulting URL should be "https://external.service/api/path"


Scenario Outline: Relative path routing depends on tenant configuration
Given an OpenAEV client with tenant_id "<tenant_id>"
When I build the URL for "/path"
Then the resulting URL should be "<output>"

Examples:
| tenant_id | output |
| None | base_url/api/path |
| 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path |
12 changes: 12 additions & 0 deletions test/bdd/features/multi_tenant_base_daemon_propagation.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: Tenant propagation in BaseDaemon API client initialization

Scenario Outline: BaseDaemon propagates tenant_id correctly from configuration
Given a daemon configuration with "<tenant_case>"
When the BaseDaemon is initialized
Then the API client should be created with tenant_id "<expected_tenant_id>"

Examples:
| tenant_case | expected_tenant_id |
| missing_key | None |
| explicit_none | None |
| valid_uuid | 2cffad3a-0001-4078-b0e2-ef74274022c3 |
Loading
Loading