Skip to content

Commit d2a332c

Browse files
authored
[client-python] feat(pyoaev): add multi-tenancy (#205)
1 parent 05c4931 commit d2a332c

15 files changed

Lines changed: 636 additions & 6 deletions

pyoaev/client.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, Optional, Union
22
from urllib import parse
3+
from uuid import UUID
34

45
import requests
56

@@ -23,6 +24,7 @@ def __init__(
2324
pagination: Optional[str] = None,
2425
order_by: Optional[str] = None,
2526
ssl_verify: Union[bool, str] = True,
27+
tenant_id: Optional[UUID] = None,
2628
**kwargs: Any,
2729
) -> None:
2830

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

3436
self.url = url
37+
self.tenant_id = tenant_id
3538
self.timeout = timeout
3639
#: Headers that will be used in request to OpenAEV
3740
self.headers = {
@@ -109,9 +112,14 @@ def _build_url(self, path: str) -> str:
109112
Returns:
110113
The full URL
111114
"""
112-
if path.startswith("http://") or path.startswith("https://"):
115+
if parse.urlparse(path).scheme in ("http", "https"):
113116
return path
114-
return f"{self.url}/api{path}"
117+
base_url = self.url.rstrip("/")
118+
normalized_path = path.lstrip("/")
119+
if self.tenant_id:
120+
return f"{base_url}/api/tenants/{self.tenant_id}/{normalized_path}"
121+
else:
122+
return f"{base_url}/api/{normalized_path}"
115123

116124
def _get_session_opts(self) -> Dict[str, Any]:
117125
return {

pyoaev/configuration/settings_loader.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import timedelta
44
from pathlib import Path
55
from typing import Annotated, Literal
6+
from uuid import UUID
67

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

103109

104110
class ConfigLoaderCollector(BaseConfigModel):

pyoaev/daemons/base_daemon.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from abc import ABC, abstractmethod
33
from inspect import signature
44
from types import FunctionType
5+
from uuid import UUID
56

67
from pyoaev.client import OpenAEV
78
from pyoaev.configuration import Configuration
@@ -37,6 +38,7 @@ def __init__(
3738
self.api = api_client or BaseDaemon.__get_default_api_client(
3839
url=self._configuration.get("openaev_url"),
3940
token=self._configuration.get("openaev_token"),
41+
tenant_id=self._configuration.get("openaev_tenant_id"),
4042
)
4143

4244
# logging
@@ -131,8 +133,8 @@ def get_id(self):
131133
)
132134

133135
@classmethod
134-
def __get_default_api_client(cls, url, token):
135-
return OpenAEV(url=url, token=token)
136+
def __get_default_api_client(cls, url, token, tenant_id: UUID | None):
137+
return OpenAEV(url=url, token=token, tenant_id=tenant_id)
136138

137139
@classmethod
138140
def __get_default_logger(cls, log_level, name):

pyoaev/helpers.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ def __init__(self, config: OpenAEVConfigHelper, icon) -> None:
322322
self.api = OpenAEV(
323323
url=config.get_conf("openaev_url"),
324324
token=config.get_conf("openaev_token"),
325+
tenant_id=config.get_conf("openaev_tenant_id"),
325326
)
326327
# Get the mq configuration from api
327328
self.config = {

pyoaev/utils.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ def __init__(self, api, config, logger, ping_type) -> None:
186186
threading.Thread.__init__(self)
187187
self.ping_type = ping_type
188188
self.api = api
189+
self.tenant_id = getattr(self.api, "tenant_id", None)
189190
self.config = config
190191
self.logger = logger
191192
self.in_error = False
@@ -203,9 +204,15 @@ def ping(self) -> None:
203204
self.exit_event.wait(40)
204205

205206
def run(self) -> None:
206-
self.logger.info("Starting PingAlive thread")
207+
self.logger.info(
208+
"Starting PingAlive thread",
209+
{"tenant_id": str(self.tenant_id) if self.tenant_id else None},
210+
)
207211
self.ping()
208212

209213
def stop(self) -> None:
210-
self.logger.info("Preparing PingAlive for clean shutdown")
214+
self.logger.info(
215+
"Preparing PingAlive for clean shutdown",
216+
{"tenant_id": str(self.tenant_id) if self.tenant_id else None},
217+
)
211218
self.exit_event.set()
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Feature: URL normalization in OpenAEV client
2+
3+
Scenario Outline: URL normalization combines base_url and path correctly
4+
Given an OpenAEV client with base_url "<base_url>"
5+
When I build the URL for "<path>"
6+
Then the resulting URL should be "<expected>"
7+
8+
Examples:
9+
| base_url | path | expected |
10+
| base_url | path | base_url/api/path |
11+
| base_url/ | /path | base_url/api/path |
12+
| base_url// | //path | base_url/api/path |
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
Feature: Tenant ID handling in OpenAEV configuration
2+
3+
Scenario: tenant_id is not provided
4+
Given a configuration without tenant_id
5+
When the configuration is loaded
6+
Then tenant_id should be None
7+
8+
9+
Scenario: tenant_id is explicitly set to None
10+
Given a configuration with tenant_id set to None
11+
When the configuration is loaded
12+
Then tenant_id should be None
13+
14+
15+
Scenario Outline: tenant_id is invalid and should raise a validation error
16+
Given a configuration with tenant_id "<tenant_id>" invalid
17+
When the configuration is loaded
18+
Then a validation error should be raised
19+
20+
Examples:
21+
| tenant_id |
22+
| ChangeMe |
23+
| "" |
24+
| 550-e29-41d-a71-446 |
25+
26+
27+
Scenario: tenant_id is a valid UUID
28+
Given a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"
29+
When the configuration is loaded
30+
Then tenant_id should be a valid UUID
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""URL normalization in OpenAEV client feature tests."""
2+
3+
import pytest
4+
from pytest_bdd import given, parsers, scenario, then, when
5+
6+
from pyoaev import OpenAEV
7+
8+
# --------------------------------------------------
9+
# SCENARIOS
10+
# --------------------------------------------------
11+
12+
13+
@scenario(
14+
"multi_tenant_api_routing_constraint.feature",
15+
"URL normalization combines base_url and path correctly",
16+
)
17+
def test_url_normalization():
18+
pass
19+
20+
21+
# --------------------------------------------------
22+
# FIXTURE CONTEXT
23+
# --------------------------------------------------
24+
25+
26+
@pytest.fixture
27+
def context():
28+
return {}
29+
30+
31+
# --------------------------------------------------
32+
# GIVEN
33+
# --------------------------------------------------
34+
35+
36+
@given(parsers.parse('an OpenAEV client with base_url "{base_url}"'))
37+
def client(context, base_url):
38+
context["client"] = OpenAEV(
39+
url=base_url,
40+
token="token",
41+
tenant_id=None,
42+
)
43+
44+
45+
# --------------------------------------------------
46+
# WHEN
47+
# --------------------------------------------------
48+
49+
50+
@when(parsers.parse('I build the URL for "{path}"'))
51+
def build_url(context, path):
52+
client = context["client"]
53+
context["result"] = client._build_url(path)
54+
55+
56+
# --------------------------------------------------
57+
# THEN
58+
# --------------------------------------------------
59+
60+
61+
@then(parsers.parse('the resulting URL should be "{expected}"'))
62+
def check_url(context, expected):
63+
assert context["result"] == expected
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
"""Tenant ID handling in OpenAEV configuration feature tests."""
2+
3+
from uuid import UUID
4+
5+
from pydantic import ValidationError
6+
from pytest_bdd import given, parsers, scenario, then, when
7+
8+
from pyoaev.configuration.settings_loader import ConfigLoaderOAEV
9+
10+
# --------------------------------------------------
11+
# SCENARIOS
12+
# --------------------------------------------------
13+
14+
15+
@scenario(
16+
"multi_tenant_validation_uuid_constraint.feature",
17+
"tenant_id is a valid UUID",
18+
)
19+
def test_tenant_id_is_a_valid_uuid():
20+
pass
21+
22+
23+
@scenario(
24+
"multi_tenant_validation_uuid_constraint.feature",
25+
"tenant_id is explicitly set to None",
26+
)
27+
def test_tenant_id_is_explicitly_set_to_none():
28+
pass
29+
30+
31+
@scenario(
32+
"multi_tenant_validation_uuid_constraint.feature",
33+
"tenant_id is invalid and should raise a validation error",
34+
)
35+
def test_tenant_id_is_invalid_and_should_raise_a_validation_error():
36+
pass
37+
38+
39+
@scenario(
40+
"multi_tenant_validation_uuid_constraint.feature",
41+
"tenant_id is not provided",
42+
)
43+
def test_tenant_id_is_not_provided():
44+
pass
45+
46+
47+
# --------------------------------------------------
48+
# GIVEN
49+
# --------------------------------------------------
50+
51+
52+
@given(
53+
"a configuration without tenant_id",
54+
target_fixture="config",
55+
)
56+
def config_without():
57+
return {
58+
"url": "https://example.com",
59+
"token": "token",
60+
}
61+
62+
63+
@given(
64+
"a configuration with tenant_id set to None",
65+
target_fixture="config",
66+
)
67+
def config_none():
68+
return {
69+
"url": "https://example.com",
70+
"token": "token",
71+
"tenant_id": None,
72+
}
73+
74+
75+
@given(
76+
parsers.parse('a configuration with tenant_id "{tenant_id}" invalid'),
77+
target_fixture="config",
78+
)
79+
def config_with_tenant_invalid(tenant_id):
80+
return {
81+
"url": "https://example.com",
82+
"token": "token",
83+
"tenant_id": tenant_id,
84+
}
85+
86+
87+
@given(
88+
'a configuration with tenant_id "2cffad3a-0001-4078-b0e2-ef74274022c3"',
89+
target_fixture="config",
90+
)
91+
def config_with_tenant_valid():
92+
return {
93+
"url": "https://example.com",
94+
"token": "token",
95+
"tenant_id": "2cffad3a-0001-4078-b0e2-ef74274022c3",
96+
}
97+
98+
99+
# --------------------------------------------------
100+
# WHEN
101+
# --------------------------------------------------
102+
103+
104+
@when(
105+
"the configuration is loaded",
106+
target_fixture="result",
107+
)
108+
def load_config(config):
109+
try:
110+
return ConfigLoaderOAEV(**config)
111+
except ValidationError as err:
112+
return err
113+
114+
115+
# --------------------------------------------------
116+
# THEN
117+
# --------------------------------------------------
118+
119+
120+
@then("tenant_id should be None")
121+
def assert_none(result):
122+
assert result.tenant_id is None
123+
124+
125+
@then("tenant_id should be a valid UUID")
126+
def assert_uuid(result):
127+
assert isinstance(result.tenant_id, UUID)
128+
129+
130+
@then("a validation error should be raised")
131+
def assert_validation_error(result):
132+
assert isinstance(result, ValidationError)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
Feature: Multi-tenant API routing in OpenAEV client
2+
3+
Scenario: Full URL bypasses tenant routing
4+
Given an OpenAEV client with any tenant configuration
5+
When I build the URL for "https://external.service/api/path"
6+
Then the resulting URL should be "https://external.service/api/path"
7+
8+
9+
Scenario Outline: Relative path routing depends on tenant configuration
10+
Given an OpenAEV client with tenant_id "<tenant_id>"
11+
When I build the URL for "/path"
12+
Then the resulting URL should be "<output>"
13+
14+
Examples:
15+
| tenant_id | output |
16+
| None | base_url/api/path |
17+
| 2cffad3a-0001-4078-b0e2-ef74274022c3 | base_url/api/tenants/2cffad3a-0001-4078-b0e2-ef74274022c3/path |

0 commit comments

Comments
 (0)