Skip to content
Draft
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
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 |
38 changes: 38 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,38 @@
import pytest

from pyoaev import OpenAEV


@pytest.mark.parametrize(
"base_url, input_path, expected",
[
(
"base_url",
"path",
"base_url/api/path",
),
(
"base_url/",
"/path",
"base_url/api/path",
),
(
"base_url//",
"//path",
"base_url/api/path",
),
],
ids=[
"clean-base-url-and-relative-path",
"base-url-trailing-slash",
"base-url-double-slash-and-path-double-slash",
],
)
def test_url_normalization(base_url, input_path, expected):
client = OpenAEV(
url=base_url,
token="token",
tenant_id=None,
)
result = client._build_url(input_path)
assert result == expected
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 tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"
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 behavior
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_id>
When the BaseDaemon is initialized
Then the API client should be created with tenant_id "<expected_tenant_id>"

Examples:
| tenant_id | expected_tenant_id |
| Missing tenant key | None |
| None | None |
| 2cffad3a-0001-4078-b0e2-ef74274022c3 | 2cffad3a-0001-4078-b0e2-ef74274022c3 |
12 changes: 12 additions & 0 deletions test/bdd/features/multi_tenant_endpoint_search_targets.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
Feature: searchTargets API routing with and without tenant_id

Scenario Outline: searchTargets routing behavior
Given an OpenAEV client with tenant_id "<tenant_id>"
And a valid SearchPaginationInput
When I call searchTargets on endpoint
Then the request URL should be "<expected_url>"

Examples:
| tenant_id | expected_url |
| None | url/api/endpoints/targets |
| 2cffad3a-0001-4078-b0e2-ef74274022c3 | url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/endpoints/targets |
46 changes: 46 additions & 0 deletions test/bdd/features/test_multi_tenant_api_routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from uuid import UUID

import pytest

from pyoaev import OpenAEV


@pytest.mark.parametrize(
"tenant_id, path, expected",
[
(
None,
"/path",
"base_url/api/path",
),
(
UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"),
"/path",
"base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path",
),
(
None,
"https://external.service/api/path",
"https://external.service/api/path",
),
(
UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"),
"https://external.service/api/path",
"https://external.service/api/path",
),
],
ids=[
"legacy-relative-path",
"tenant-relative-path",
"legacy-full-url-bypass",
"tenant-full-url-bypass",
],
)
def test_build_url_behavior(tenant_id, path, expected):
client = OpenAEV(
"base_url",
"token",
tenant_id=tenant_id,
)
result = client._build_url(path)
assert result == expected
72 changes: 72 additions & 0 deletions test/bdd/features/test_multi_tenant_base_daemon_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from unittest.mock import MagicMock
from uuid import UUID

import pytest

from pyoaev.daemons.base_daemon import BaseDaemon


class DummyDaemon(BaseDaemon):
def _setup(self):
pass

def _start_loop(self):
pass


@pytest.mark.parametrize(
"config_map, expected_tenant",
[
(
{
"openaev_url": "url",
"openaev_token": "token",
},
None,
),
(
{
"openaev_url": "url",
"openaev_token": "token",
"openaev_tenant_id": None,
},
None,
),
(
{
"openaev_url": "url",
"openaev_token": "token",
"openaev_tenant_id": UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"),
},
UUID("2cffad3a-0001-4078-b0e2-ef74274022c3"),
),
],
ids=[
"missing_tenant_key",
"explicit_none_tenant",
"valid_uuid_tenant",
],
)
def test_default_api_client_propagates_tenant_id(
monkeypatch, config_map, expected_tenant
):
captured = {}

def fake_client(url, token, tenant_id=None):
captured["url"] = url
captured["token"] = token
captured["tenant_id"] = tenant_id
return MagicMock()

monkeypatch.setattr("pyoaev.daemons.base_daemon.OpenAEV", fake_client)

config = MagicMock()
config.get.side_effect = lambda key: config_map.get(key)

daemon = DummyDaemon(configuration=config)
assert daemon.api is not None

assert "openaev_tenant_id" in config_map or expected_tenant is None
assert captured["url"] == "url"
assert captured["token"] == "token"
assert captured["tenant_id"] == expected_tenant
Loading
Loading