nkz-platform-sdk 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nkz_platform_sdk-0.3.0/PKG-INFO +13 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/__init__.py +31 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/auth.py +92 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/config.py +77 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/lifecycle.py +58 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/module_app.py +175 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/orion.py +167 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk/timescale.py +128 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk.egg-info/PKG-INFO +13 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk.egg-info/SOURCES.txt +15 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk.egg-info/dependency_links.txt +1 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk.egg-info/requires.txt +8 -0
- nkz_platform_sdk-0.3.0/nkz_platform_sdk.egg-info/top_level.txt +1 -0
- nkz_platform_sdk-0.3.0/pyproject.toml +22 -0
- nkz_platform_sdk-0.3.0/setup.cfg +4 -0
- nkz_platform_sdk-0.3.0/tests/test_module_app.py +142 -0
- nkz_platform_sdk-0.3.0/tests/test_timescale.py +116 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nkz-platform-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Nekazari Platform SDK for module backends
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: fastapi>=0.109.0
|
|
8
|
+
Requires-Dist: httpx>=0.26.0
|
|
9
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
10
|
+
Requires-Dist: cryptography>=42.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Nekazari Platform SDK — Backend module development kit.
|
|
3
|
+
|
|
4
|
+
Provides:
|
|
5
|
+
- ModuleApp: FastAPI subclass pre-wired with CORS, /health, JSON logs, auth helpers
|
|
6
|
+
- require_auth: FastAPI dependency for authenticated routes
|
|
7
|
+
- OrionClient: Typed NGSI-LD client with automatic tenant header injection
|
|
8
|
+
- TimescaleClient: Typed timeseries reader, tenant headers auto-injected
|
|
9
|
+
- ModuleLifecycle: Base class for install/uninstall/enable/disable hooks
|
|
10
|
+
- ModuleConfig: Per-tenant encrypted configuration storage
|
|
11
|
+
|
|
12
|
+
License: Apache-2.0 — modules using this SDK may use any license.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from nkz_platform_sdk.auth import require_auth, AuthContext
|
|
16
|
+
from nkz_platform_sdk.module_app import ModuleApp
|
|
17
|
+
from nkz_platform_sdk.orion import OrionClient
|
|
18
|
+
from nkz_platform_sdk.timescale import TimescaleClient
|
|
19
|
+
from nkz_platform_sdk.lifecycle import ModuleLifecycle, LifecycleResult
|
|
20
|
+
from nkz_platform_sdk.config import ModuleConfig
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ModuleApp",
|
|
24
|
+
"require_auth",
|
|
25
|
+
"AuthContext",
|
|
26
|
+
"OrionClient",
|
|
27
|
+
"TimescaleClient",
|
|
28
|
+
"ModuleLifecycle",
|
|
29
|
+
"LifecycleResult",
|
|
30
|
+
"ModuleConfig",
|
|
31
|
+
]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication dependency for module backends.
|
|
3
|
+
|
|
4
|
+
The api-gateway validates the JWT and injects headers:
|
|
5
|
+
X-Tenant-ID, X-User-ID, X-User-Roles, X-Request-ID
|
|
6
|
+
|
|
7
|
+
This module provides require_auth() which reads those headers.
|
|
8
|
+
Module backends do NOT validate JWT signatures — the gateway did that.
|
|
9
|
+
Defense in depth: we still validate header presence and format.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Sequence
|
|
15
|
+
from fastapi import Request, HTTPException, Depends
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class AuthContext:
|
|
20
|
+
"""Authenticated request context injected by the gateway."""
|
|
21
|
+
|
|
22
|
+
tenant_id: str
|
|
23
|
+
user_id: str
|
|
24
|
+
roles: tuple[str, ...]
|
|
25
|
+
request_id: str | None = None
|
|
26
|
+
|
|
27
|
+
def has_role(self, role: str) -> bool:
|
|
28
|
+
return role in self.roles
|
|
29
|
+
|
|
30
|
+
def has_any_role(self, roles: Sequence[str]) -> bool:
|
|
31
|
+
return any(r in self.roles for r in roles)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def require_auth(roles: Sequence[str] | None = None):
|
|
35
|
+
"""
|
|
36
|
+
FastAPI dependency that validates gateway-injected auth headers.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
roles: If provided, at least one role must match.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
FastAPI dependency yielding AuthContext.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
HTTPException 401 if headers are missing or invalid.
|
|
46
|
+
HTTPException 403 if roles are provided and none match.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
async def _require_auth(request: Request) -> AuthContext:
|
|
50
|
+
tenant_id = request.headers.get("X-Tenant-ID", "").strip()
|
|
51
|
+
user_id = request.headers.get("X-User-ID", "").strip()
|
|
52
|
+
roles_header = request.headers.get("X-User-Roles", "").strip()
|
|
53
|
+
request_id = request.headers.get("X-Request-ID", "").strip() or None
|
|
54
|
+
|
|
55
|
+
# Validate presence
|
|
56
|
+
if not tenant_id:
|
|
57
|
+
raise HTTPException(
|
|
58
|
+
status_code=401,
|
|
59
|
+
detail="Missing X-Tenant-ID header — gateway misconfiguration?",
|
|
60
|
+
)
|
|
61
|
+
if not user_id:
|
|
62
|
+
raise HTTPException(
|
|
63
|
+
status_code=401,
|
|
64
|
+
detail="Missing X-User-ID header — gateway misconfiguration?",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# Validate tenant_id format (alphanumeric + underscore + hyphen, 3-63 chars)
|
|
68
|
+
if not re.match(r"^[a-z0-9_-]{3,63}$", tenant_id):
|
|
69
|
+
raise HTTPException(
|
|
70
|
+
status_code=401,
|
|
71
|
+
detail=f"Invalid X-Tenant-ID format: {tenant_id}",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
user_roles = tuple(r.strip() for r in roles_header.split(",") if r.strip())
|
|
75
|
+
|
|
76
|
+
# Role check (defense in depth — gateway also checks)
|
|
77
|
+
if roles is not None:
|
|
78
|
+
allowed = set(roles)
|
|
79
|
+
if not any(r in allowed for r in user_roles):
|
|
80
|
+
raise HTTPException(
|
|
81
|
+
status_code=403,
|
|
82
|
+
detail=f"Access denied. Required one of: {roles}",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return AuthContext(
|
|
86
|
+
tenant_id=tenant_id,
|
|
87
|
+
user_id=user_id,
|
|
88
|
+
roles=user_roles,
|
|
89
|
+
request_id=request_id,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return Depends(_require_auth)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ModuleConfig — per-tenant encrypted configuration storage.
|
|
3
|
+
|
|
4
|
+
Values are encrypted at rest using Fernet symmetric encryption.
|
|
5
|
+
The encryption key is derived from MODULE_CONFIG_SECRET env var.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from base64 import urlsafe_b64encode
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
|
|
12
|
+
from cryptography.fernet import Fernet
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ModuleConfig:
|
|
17
|
+
"""Per-tenant encrypted configuration backed by platform API."""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
module_id: str,
|
|
22
|
+
tenant_id: str,
|
|
23
|
+
api_url: str | None = None,
|
|
24
|
+
):
|
|
25
|
+
self.module_id = module_id
|
|
26
|
+
self.tenant_id = tenant_id
|
|
27
|
+
self._api_url = api_url or os.getenv(
|
|
28
|
+
"MODULE_CONFIG_API",
|
|
29
|
+
"http://entity-manager-service:5000/api/internal/module-config",
|
|
30
|
+
)
|
|
31
|
+
self._secret = os.getenv("MODULE_CONFIG_SECRET")
|
|
32
|
+
self._fernet: Fernet | None = None
|
|
33
|
+
if self._secret:
|
|
34
|
+
key = urlsafe_b64encode(sha256(self._secret.encode()).digest())
|
|
35
|
+
self._fernet = Fernet(key)
|
|
36
|
+
|
|
37
|
+
async def get(self, key: str) -> str | None:
|
|
38
|
+
resp = await self._request("GET", key)
|
|
39
|
+
if resp.status_code == 404:
|
|
40
|
+
return None
|
|
41
|
+
resp.raise_for_status()
|
|
42
|
+
value = resp.json().get("value")
|
|
43
|
+
if value and self._fernet:
|
|
44
|
+
value = self._fernet.decrypt(value.encode()).decode()
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
async def set(self, key: str, value: str) -> None:
|
|
48
|
+
if self._fernet:
|
|
49
|
+
value = self._fernet.encrypt(value.encode()).decode()
|
|
50
|
+
await self._request("POST", key, {"value": value})
|
|
51
|
+
|
|
52
|
+
async def delete(self, key: str) -> None:
|
|
53
|
+
await self._request("DELETE", key)
|
|
54
|
+
|
|
55
|
+
async def list_keys(self) -> list[str]:
|
|
56
|
+
resp = await self._request("GET", "")
|
|
57
|
+
resp.raise_for_status()
|
|
58
|
+
return resp.json().get("keys", [])
|
|
59
|
+
|
|
60
|
+
async def _request(
|
|
61
|
+
self, method: str, key: str, data: dict | None = None
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
url = f"{self._api_url}/{self.module_id}/{self.tenant_id}"
|
|
64
|
+
if key:
|
|
65
|
+
url = f"{url}/{key}"
|
|
66
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
67
|
+
headers = {
|
|
68
|
+
"X-Internal-Service": "nkz-platform-sdk",
|
|
69
|
+
"Content-Type": "application/json",
|
|
70
|
+
}
|
|
71
|
+
if method == "GET":
|
|
72
|
+
return await client.get(url, headers=headers)
|
|
73
|
+
elif method == "POST":
|
|
74
|
+
return await client.post(url, json=data, headers=headers)
|
|
75
|
+
elif method == "DELETE":
|
|
76
|
+
return await client.delete(url, headers=headers)
|
|
77
|
+
raise ValueError(f"Unknown method: {method}")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ModuleLifecycle — base class for module lifecycle hooks.
|
|
3
|
+
|
|
4
|
+
The platform calls lifecycle webhooks via HMAC-signed HTTP POST.
|
|
5
|
+
Modules extend this class and implement on_install/on_uninstall/etc.
|
|
6
|
+
|
|
7
|
+
Guarantees provided by the platform:
|
|
8
|
+
- Idempotency: same call N times = same result
|
|
9
|
+
- Retry: exponential backoff (3 attempts: 1s, 4s, 16s)
|
|
10
|
+
- Dead-letter: after retries exhausted, logged for manual intervention
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class LifecycleResult:
|
|
19
|
+
status: str # "active" | "failed" | "pending"
|
|
20
|
+
message: str = ""
|
|
21
|
+
resources: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ModuleLifecycle:
|
|
25
|
+
"""Base class for module lifecycle hooks. Override any hook needed."""
|
|
26
|
+
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self._handlers: dict[str, Any] = {}
|
|
29
|
+
|
|
30
|
+
def on_install(self, func):
|
|
31
|
+
self._handlers["install"] = func
|
|
32
|
+
return func
|
|
33
|
+
|
|
34
|
+
def on_uninstall(self, func):
|
|
35
|
+
self._handlers["uninstall"] = func
|
|
36
|
+
return func
|
|
37
|
+
|
|
38
|
+
def on_enable(self, func):
|
|
39
|
+
self._handlers["enable"] = func
|
|
40
|
+
return func
|
|
41
|
+
|
|
42
|
+
def on_disable(self, func):
|
|
43
|
+
self._handlers["disable"] = func
|
|
44
|
+
return func
|
|
45
|
+
|
|
46
|
+
async def handle(
|
|
47
|
+
self, event: str, tenant_id: str, config: dict | None = None
|
|
48
|
+
) -> LifecycleResult:
|
|
49
|
+
handler = self._handlers.get(event)
|
|
50
|
+
if handler is None:
|
|
51
|
+
return LifecycleResult(
|
|
52
|
+
status="active",
|
|
53
|
+
message=f"No handler registered for '{event}' — noop",
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
return await handler(tenant_id, config or {})
|
|
57
|
+
except Exception as e:
|
|
58
|
+
return LifecycleResult(status="failed", message=str(e))
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ModuleApp — FastAPI subclass pre-wired for Nekazari module backends.
|
|
3
|
+
|
|
4
|
+
What you write:
|
|
5
|
+
from nkz_platform_sdk import ModuleApp, AuthContext
|
|
6
|
+
|
|
7
|
+
app = ModuleApp(id="soil-health", description="Soil Health backend")
|
|
8
|
+
|
|
9
|
+
@app.get("/parcels/{parcel_id}/analysis")
|
|
10
|
+
async def analysis(parcel_id: str, ctx: AuthContext = app.auth()):
|
|
11
|
+
orion = app.orion(ctx)
|
|
12
|
+
return await orion.get_entity(parcel_id)
|
|
13
|
+
|
|
14
|
+
What you get for free:
|
|
15
|
+
- CORS configured from `ALLOWED_ORIGINS` env (comma-separated, default same-origin only).
|
|
16
|
+
- `/health` and `/ready` endpoints exempt from auth and rate limit.
|
|
17
|
+
- JSON structured logs with `tenant_id`, `user_id`, `module_id`, `trace_id` (when reachable).
|
|
18
|
+
- OpenAPI at `/openapi.json` (FastAPI native — keep your endpoints typed for free docs).
|
|
19
|
+
- `app.auth(roles=...)` shortcut → `require_auth(roles)`.
|
|
20
|
+
- `app.orion(ctx)` → an `OrionClient` scoped to the authenticated tenant.
|
|
21
|
+
- `app.timescale(ctx)` → a `TimescaleClient` scoped to the authenticated tenant.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
import uuid
|
|
29
|
+
from typing import Any, Sequence
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI, Request
|
|
32
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
33
|
+
from fastapi.responses import JSONResponse
|
|
34
|
+
|
|
35
|
+
from nkz_platform_sdk.auth import AuthContext, require_auth
|
|
36
|
+
from nkz_platform_sdk.orion import OrionClient
|
|
37
|
+
from nkz_platform_sdk.timescale import TimescaleClient
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_origins(env_value: str | None) -> list[str]:
|
|
41
|
+
if not env_value:
|
|
42
|
+
return []
|
|
43
|
+
return [o.strip() for o in env_value.split(",") if o.strip()]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _JsonFormatter(logging.Formatter):
|
|
47
|
+
"""Single-line JSON log records — Loki / Cloud Logging / Datadog friendly."""
|
|
48
|
+
|
|
49
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
50
|
+
payload: dict[str, Any] = {
|
|
51
|
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
|
|
52
|
+
"level": record.levelname,
|
|
53
|
+
"logger": record.name,
|
|
54
|
+
"msg": record.getMessage(),
|
|
55
|
+
}
|
|
56
|
+
for attr in ("module_id", "tenant_id", "user_id", "trace_id"):
|
|
57
|
+
v = getattr(record, attr, None)
|
|
58
|
+
if v is not None:
|
|
59
|
+
payload[attr] = v
|
|
60
|
+
if record.exc_info:
|
|
61
|
+
payload["exc"] = self.formatException(record.exc_info)
|
|
62
|
+
return json.dumps(payload, default=str)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _configure_json_logging(module_id: str) -> None:
|
|
66
|
+
"""Replace the root logger's handlers with one that emits JSON.
|
|
67
|
+
Idempotent — re-applying ModuleApp() in tests won't keep stacking handlers.
|
|
68
|
+
"""
|
|
69
|
+
root = logging.getLogger()
|
|
70
|
+
for h in list(root.handlers):
|
|
71
|
+
root.removeHandler(h)
|
|
72
|
+
handler = logging.StreamHandler()
|
|
73
|
+
handler.setFormatter(_JsonFormatter())
|
|
74
|
+
root.addHandler(handler)
|
|
75
|
+
root.setLevel(os.getenv("LOG_LEVEL", "INFO").upper())
|
|
76
|
+
# Attach module_id to every record via a filter
|
|
77
|
+
module_filter = logging.Filter()
|
|
78
|
+
module_filter.filter = lambda r: setattr(r, "module_id", module_id) or True # type: ignore[assignment]
|
|
79
|
+
root.addFilter(module_filter)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ModuleApp(FastAPI):
|
|
83
|
+
"""FastAPI app pre-cabled with everything a Nekazari module backend needs."""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
id: str,
|
|
88
|
+
description: str = "",
|
|
89
|
+
version: str = "0.1.0",
|
|
90
|
+
allowed_origins: Sequence[str] | None = None,
|
|
91
|
+
configure_logging: bool = True,
|
|
92
|
+
**fastapi_kwargs: Any,
|
|
93
|
+
) -> None:
|
|
94
|
+
super().__init__(
|
|
95
|
+
title=f"Nekazari module: {id}",
|
|
96
|
+
description=description,
|
|
97
|
+
version=version,
|
|
98
|
+
**fastapi_kwargs,
|
|
99
|
+
)
|
|
100
|
+
self.module_id = id
|
|
101
|
+
|
|
102
|
+
if configure_logging:
|
|
103
|
+
_configure_json_logging(id)
|
|
104
|
+
|
|
105
|
+
origins = (
|
|
106
|
+
list(allowed_origins)
|
|
107
|
+
if allowed_origins is not None
|
|
108
|
+
else _parse_origins(os.getenv("ALLOWED_ORIGINS"))
|
|
109
|
+
)
|
|
110
|
+
if origins:
|
|
111
|
+
self.add_middleware(
|
|
112
|
+
CORSMiddleware,
|
|
113
|
+
allow_origins=origins,
|
|
114
|
+
allow_credentials=True,
|
|
115
|
+
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
116
|
+
allow_headers=[
|
|
117
|
+
"Authorization",
|
|
118
|
+
"Content-Type",
|
|
119
|
+
"X-Tenant-ID",
|
|
120
|
+
"X-Module-Id",
|
|
121
|
+
"X-User-ID",
|
|
122
|
+
"X-User-Roles",
|
|
123
|
+
"X-Request-ID",
|
|
124
|
+
"Cookie",
|
|
125
|
+
],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# Request-scope trace id + access log
|
|
129
|
+
@self.middleware("http")
|
|
130
|
+
async def _request_scope(request: Request, call_next: Any) -> Any:
|
|
131
|
+
trace_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
|
|
132
|
+
start = time.monotonic()
|
|
133
|
+
response = await call_next(request)
|
|
134
|
+
elapsed_ms = int((time.monotonic() - start) * 1000)
|
|
135
|
+
logging.getLogger("nkz.access").info(
|
|
136
|
+
"%s %s %s in %dms",
|
|
137
|
+
request.method,
|
|
138
|
+
request.url.path,
|
|
139
|
+
response.status_code,
|
|
140
|
+
elapsed_ms,
|
|
141
|
+
extra={
|
|
142
|
+
"tenant_id": request.headers.get("X-Tenant-ID"),
|
|
143
|
+
"user_id": request.headers.get("X-User-ID"),
|
|
144
|
+
"trace_id": trace_id,
|
|
145
|
+
},
|
|
146
|
+
)
|
|
147
|
+
response.headers["X-Request-ID"] = trace_id
|
|
148
|
+
return response
|
|
149
|
+
|
|
150
|
+
@self.get("/health", include_in_schema=False)
|
|
151
|
+
async def _health() -> dict[str, str]:
|
|
152
|
+
return {"status": "ok", "module": id}
|
|
153
|
+
|
|
154
|
+
@self.get("/ready", include_in_schema=False)
|
|
155
|
+
async def _ready() -> dict[str, str]:
|
|
156
|
+
return {"status": "ready", "module": id}
|
|
157
|
+
|
|
158
|
+
@self.exception_handler(Exception)
|
|
159
|
+
async def _unhandled(_request: Request, exc: Exception) -> JSONResponse:
|
|
160
|
+
logging.getLogger("nkz.error").exception("unhandled exception: %s", exc)
|
|
161
|
+
return JSONResponse(
|
|
162
|
+
status_code=500, content={"error": "internal", "detail": str(exc)}
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def auth(self, roles: Sequence[str] | None = None) -> Any:
|
|
166
|
+
"""Shortcut for `require_auth(roles)`."""
|
|
167
|
+
return require_auth(roles)
|
|
168
|
+
|
|
169
|
+
def orion(self, ctx: AuthContext) -> OrionClient:
|
|
170
|
+
"""Create an OrionClient scoped to the authenticated tenant."""
|
|
171
|
+
return OrionClient(ctx.tenant_id)
|
|
172
|
+
|
|
173
|
+
def timescale(self, ctx: AuthContext) -> TimescaleClient:
|
|
174
|
+
"""Create a TimescaleClient scoped to the authenticated tenant."""
|
|
175
|
+
return TimescaleClient(ctx.tenant_id)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OrionClient — typed NGSI-LD client with automatic FIWARE header injection.
|
|
3
|
+
|
|
4
|
+
Rules enforced at library level (impossible to forget):
|
|
5
|
+
- Every request sends NGSILD-Tenant AND Fiware-Service headers
|
|
6
|
+
- Content-Type: application/ld+json with @context in body
|
|
7
|
+
- Or Content-Type: application/json with Link header
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import urljoin
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
CONTEXT_URL = os.getenv(
|
|
17
|
+
"CONTEXT_URL",
|
|
18
|
+
"http://api-gateway-service:5000/ngsi-ld-context.json",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class OrionClient:
|
|
23
|
+
"""NGSI-LD client scoped to a single tenant."""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
tenant_id: str,
|
|
28
|
+
base_url: str | None = None,
|
|
29
|
+
context_url: str | None = None,
|
|
30
|
+
):
|
|
31
|
+
self.tenant_id = tenant_id
|
|
32
|
+
self.base_url = base_url or os.getenv(
|
|
33
|
+
"ORION_LD_URL", "http://orion-ld-service:1026"
|
|
34
|
+
)
|
|
35
|
+
self.context_url = context_url or CONTEXT_URL
|
|
36
|
+
self._client = httpx.AsyncClient(timeout=30.0)
|
|
37
|
+
|
|
38
|
+
def _headers(self, content_type: str = "application/ld+json") -> dict[str, str]:
|
|
39
|
+
headers = {
|
|
40
|
+
"NGSILD-Tenant": self.tenant_id,
|
|
41
|
+
"Fiware-Service": self.tenant_id,
|
|
42
|
+
"Fiware-ServicePath": "/",
|
|
43
|
+
}
|
|
44
|
+
if content_type == "application/ld+json":
|
|
45
|
+
headers["Content-Type"] = "application/ld+json"
|
|
46
|
+
elif content_type == "application/json":
|
|
47
|
+
headers["Content-Type"] = "application/json"
|
|
48
|
+
headers["Link"] = (
|
|
49
|
+
f'<{self.context_url}>; rel="http://www.w3.org/ns/json-ld#context";'
|
|
50
|
+
' type="application/ld+json"'
|
|
51
|
+
)
|
|
52
|
+
return headers
|
|
53
|
+
|
|
54
|
+
def _url(self, path: str) -> str:
|
|
55
|
+
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
56
|
+
|
|
57
|
+
async def query_entities(
|
|
58
|
+
self,
|
|
59
|
+
type: str | None = None,
|
|
60
|
+
q: str | None = None,
|
|
61
|
+
limit: int = 100,
|
|
62
|
+
offset: int = 0,
|
|
63
|
+
attrs: str | None = None,
|
|
64
|
+
) -> list[dict[str, Any]]:
|
|
65
|
+
params: dict[str, Any] = {"limit": limit, "offset": offset}
|
|
66
|
+
if type:
|
|
67
|
+
params["type"] = type
|
|
68
|
+
if q:
|
|
69
|
+
params["q"] = q
|
|
70
|
+
if attrs:
|
|
71
|
+
params["attrs"] = attrs
|
|
72
|
+
resp = await self._client.get(
|
|
73
|
+
self._url("/ngsi-ld/v1/entities"),
|
|
74
|
+
params=params,
|
|
75
|
+
headers=self._headers("application/json"),
|
|
76
|
+
)
|
|
77
|
+
resp.raise_for_status()
|
|
78
|
+
return resp.json()
|
|
79
|
+
|
|
80
|
+
async def get_entity(self, entity_id: str) -> dict[str, Any]:
|
|
81
|
+
resp = await self._client.get(
|
|
82
|
+
self._url(f"/ngsi-ld/v1/entities/{entity_id}"),
|
|
83
|
+
headers=self._headers("application/json"),
|
|
84
|
+
)
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
return resp.json()
|
|
87
|
+
|
|
88
|
+
def _ensure_context(self, entity: dict[str, Any]) -> dict[str, Any]:
|
|
89
|
+
if "@context" not in entity:
|
|
90
|
+
return {"@context": [self.context_url], **entity}
|
|
91
|
+
return entity
|
|
92
|
+
|
|
93
|
+
async def create_entity(self, entity: dict[str, Any]) -> dict[str, Any]:
|
|
94
|
+
entity = self._ensure_context(entity)
|
|
95
|
+
resp = await self._client.post(
|
|
96
|
+
self._url("/ngsi-ld/v1/entities"),
|
|
97
|
+
json=entity,
|
|
98
|
+
headers=self._headers("application/ld+json"),
|
|
99
|
+
)
|
|
100
|
+
resp.raise_for_status()
|
|
101
|
+
location = resp.headers.get("Location", "")
|
|
102
|
+
return {"id": location.split("/")[-1] if location else "", "status": "created"}
|
|
103
|
+
|
|
104
|
+
async def create_entities_batch(
|
|
105
|
+
self,
|
|
106
|
+
entities: list[dict[str, Any]],
|
|
107
|
+
) -> dict[str, Any]:
|
|
108
|
+
"""Create multiple entities via POST /ngsi-ld/v1/entityOperations/create.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
dict with keys ``created``, ``errors``, ``entity_ids``.
|
|
112
|
+
Raises:
|
|
113
|
+
httpx.HTTPStatusError: on non-batchable failure (caller may fall back).
|
|
114
|
+
"""
|
|
115
|
+
prepared = [self._ensure_context(e) for e in entities]
|
|
116
|
+
if not prepared:
|
|
117
|
+
return {"created": 0, "errors": [], "entity_ids": []}
|
|
118
|
+
|
|
119
|
+
resp = await self._client.post(
|
|
120
|
+
self._url("/ngsi-ld/v1/entityOperations/create"),
|
|
121
|
+
json=prepared,
|
|
122
|
+
headers=self._headers("application/ld+json"),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
entity_ids = [e["id"] for e in prepared if e.get("id")]
|
|
126
|
+
|
|
127
|
+
if resp.status_code in (200, 201, 204):
|
|
128
|
+
return {
|
|
129
|
+
"created": len(prepared),
|
|
130
|
+
"errors": [],
|
|
131
|
+
"entity_ids": entity_ids,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if resp.status_code == 207:
|
|
135
|
+
body = resp.json() if resp.content else {}
|
|
136
|
+
success = body.get("success", entity_ids)
|
|
137
|
+
errors = body.get("errors", [])
|
|
138
|
+
if isinstance(success, list) and success and isinstance(success[0], dict):
|
|
139
|
+
success_ids = [s.get("id", "") for s in success if s.get("id")]
|
|
140
|
+
else:
|
|
141
|
+
success_ids = success if isinstance(success, list) else entity_ids
|
|
142
|
+
return {
|
|
143
|
+
"created": len(success_ids),
|
|
144
|
+
"errors": errors,
|
|
145
|
+
"entity_ids": success_ids,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
resp.raise_for_status()
|
|
149
|
+
return {"created": 0, "errors": [], "entity_ids": []}
|
|
150
|
+
|
|
151
|
+
async def update_entity_attrs(self, entity_id: str, attrs: dict[str, Any]) -> None:
|
|
152
|
+
resp = await self._client.patch(
|
|
153
|
+
self._url(f"/ngsi-ld/v1/entities/{entity_id}/attrs"),
|
|
154
|
+
json=attrs,
|
|
155
|
+
headers=self._headers("application/ld+json"),
|
|
156
|
+
)
|
|
157
|
+
resp.raise_for_status()
|
|
158
|
+
|
|
159
|
+
async def delete_entity(self, entity_id: str) -> None:
|
|
160
|
+
resp = await self._client.delete(
|
|
161
|
+
self._url(f"/ngsi-ld/v1/entities/{entity_id}"),
|
|
162
|
+
headers=self._headers(),
|
|
163
|
+
)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
|
|
166
|
+
async def close(self) -> None:
|
|
167
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TimescaleClient — typed read-only client for the platform timeseries store.
|
|
3
|
+
|
|
4
|
+
Design constraint (defense in depth): tenant isolation lives in the
|
|
5
|
+
`timeseries-reader-service`, which owns Postgres RLS and is the only service
|
|
6
|
+
holding DB credentials. Module backends MUST NOT open direct DB connections —
|
|
7
|
+
they go through HTTP to that service, and the SDK auto-injects the canonical
|
|
8
|
+
tenant headers so a module cannot accidentally read another tenant's data.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
from nkz_platform_sdk import ModuleApp, AuthContext
|
|
12
|
+
|
|
13
|
+
app = ModuleApp(id="soil-health")
|
|
14
|
+
|
|
15
|
+
@app.get("/parcels/{pid}/moisture")
|
|
16
|
+
async def moisture(pid: str, ctx: AuthContext = app.auth()):
|
|
17
|
+
ts = app.timescale(ctx)
|
|
18
|
+
return await ts.query(
|
|
19
|
+
entity_id=f"urn:ngsi-ld:AgriParcel:{pid}",
|
|
20
|
+
attribute="soilMoisture",
|
|
21
|
+
since="2026-05-01T00:00:00Z",
|
|
22
|
+
)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import Any
|
|
28
|
+
from urllib.parse import urljoin
|
|
29
|
+
|
|
30
|
+
import httpx
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _iso(value: str | datetime) -> str:
|
|
34
|
+
if isinstance(value, datetime):
|
|
35
|
+
return value.isoformat()
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TimescaleClient:
|
|
40
|
+
"""Read-only timeseries client scoped to a single tenant.
|
|
41
|
+
|
|
42
|
+
All calls go through `timeseries-reader-service`, which enforces
|
|
43
|
+
Postgres row-level security based on the injected tenant headers.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
tenant_id: str,
|
|
49
|
+
base_url: str | None = None,
|
|
50
|
+
):
|
|
51
|
+
self.tenant_id = tenant_id
|
|
52
|
+
self.base_url = base_url or os.getenv(
|
|
53
|
+
"TIMESERIES_READER_URL",
|
|
54
|
+
"http://timeseries-reader-service:5000",
|
|
55
|
+
)
|
|
56
|
+
self._client = httpx.AsyncClient(timeout=30.0)
|
|
57
|
+
|
|
58
|
+
def _headers(self) -> dict[str, str]:
|
|
59
|
+
return {
|
|
60
|
+
"X-Tenant-ID": self.tenant_id,
|
|
61
|
+
"NGSILD-Tenant": self.tenant_id,
|
|
62
|
+
"Fiware-Service": self.tenant_id,
|
|
63
|
+
"Content-Type": "application/json",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def _url(self, path: str) -> str:
|
|
67
|
+
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
68
|
+
|
|
69
|
+
async def query(
|
|
70
|
+
self,
|
|
71
|
+
entity_id: str,
|
|
72
|
+
attribute: str,
|
|
73
|
+
since: str | datetime,
|
|
74
|
+
until: str | datetime | None = None,
|
|
75
|
+
limit: int = 1000,
|
|
76
|
+
) -> list[dict[str, Any]]:
|
|
77
|
+
"""Fetch a single attribute timeseries for a single entity.
|
|
78
|
+
|
|
79
|
+
Returns a list of `{"ts": iso8601, "value": float}` points, oldest first.
|
|
80
|
+
Non-numeric points are silently filtered out.
|
|
81
|
+
"""
|
|
82
|
+
params: dict[str, Any] = {
|
|
83
|
+
"entityId": entity_id,
|
|
84
|
+
"attribute": attribute,
|
|
85
|
+
"since": _iso(since),
|
|
86
|
+
"limit": limit,
|
|
87
|
+
}
|
|
88
|
+
if until is not None:
|
|
89
|
+
params["until"] = _iso(until)
|
|
90
|
+
resp = await self._client.get(
|
|
91
|
+
self._url("/api/timeseries"),
|
|
92
|
+
params=params,
|
|
93
|
+
headers=self._headers(),
|
|
94
|
+
)
|
|
95
|
+
resp.raise_for_status()
|
|
96
|
+
raw = resp.json()
|
|
97
|
+
points = raw.get("points", raw) if isinstance(raw, dict) else raw
|
|
98
|
+
normalised: list[dict[str, Any]] = []
|
|
99
|
+
for p in points or []:
|
|
100
|
+
ts = p.get("ts") or p.get("timestamp") or p.get("time")
|
|
101
|
+
val = p.get("value") if "value" in p else p.get("v")
|
|
102
|
+
if ts is None or val is None:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
normalised.append({"ts": ts, "value": float(val)})
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
continue
|
|
108
|
+
return normalised
|
|
109
|
+
|
|
110
|
+
async def query_aggregate(
|
|
111
|
+
self,
|
|
112
|
+
body: dict[str, Any],
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Run a multi-series aggregation against `/api/v2/query`.
|
|
115
|
+
|
|
116
|
+
The body is forwarded verbatim — see timeseries-reader-service for the
|
|
117
|
+
accepted shape (entityIds, attributes, aggregation window, etc.).
|
|
118
|
+
"""
|
|
119
|
+
resp = await self._client.post(
|
|
120
|
+
self._url("/api/v2/query"),
|
|
121
|
+
json=body,
|
|
122
|
+
headers=self._headers(),
|
|
123
|
+
)
|
|
124
|
+
resp.raise_for_status()
|
|
125
|
+
return resp.json()
|
|
126
|
+
|
|
127
|
+
async def close(self) -> None:
|
|
128
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nkz-platform-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Nekazari Platform SDK for module backends
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: fastapi>=0.109.0
|
|
8
|
+
Requires-Dist: httpx>=0.26.0
|
|
9
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
10
|
+
Requires-Dist: cryptography>=42.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
13
|
+
Requires-Dist: pytest-asyncio>=0.23.0; extra == "dev"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
nkz_platform_sdk/__init__.py
|
|
3
|
+
nkz_platform_sdk/auth.py
|
|
4
|
+
nkz_platform_sdk/config.py
|
|
5
|
+
nkz_platform_sdk/lifecycle.py
|
|
6
|
+
nkz_platform_sdk/module_app.py
|
|
7
|
+
nkz_platform_sdk/orion.py
|
|
8
|
+
nkz_platform_sdk/timescale.py
|
|
9
|
+
nkz_platform_sdk.egg-info/PKG-INFO
|
|
10
|
+
nkz_platform_sdk.egg-info/SOURCES.txt
|
|
11
|
+
nkz_platform_sdk.egg-info/dependency_links.txt
|
|
12
|
+
nkz_platform_sdk.egg-info/requires.txt
|
|
13
|
+
nkz_platform_sdk.egg-info/top_level.txt
|
|
14
|
+
tests/test_module_app.py
|
|
15
|
+
tests/test_timescale.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nkz_platform_sdk
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "nkz-platform-sdk"
|
|
7
|
+
version = "0.3.0"
|
|
8
|
+
description = "Nekazari Platform SDK for module backends"
|
|
9
|
+
license = {text = "Apache-2.0"}
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"fastapi>=0.109.0",
|
|
13
|
+
"httpx>=0.26.0",
|
|
14
|
+
"python-jose[cryptography]>=3.3.0",
|
|
15
|
+
"cryptography>=42.0.0",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = [
|
|
20
|
+
"pytest>=7.4.0",
|
|
21
|
+
"pytest-asyncio>=0.23.0",
|
|
22
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Unit tests for ModuleApp — uses FastAPI TestClient (sync)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from fastapi.testclient import TestClient
|
|
6
|
+
|
|
7
|
+
from nkz_platform_sdk import ModuleApp, AuthContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def make_client(**kwargs) -> TestClient:
|
|
11
|
+
# configure_logging=False to avoid mutating root logger between tests
|
|
12
|
+
app = ModuleApp(id="testmod", configure_logging=False, **kwargs)
|
|
13
|
+
|
|
14
|
+
@app.get("/protected")
|
|
15
|
+
async def protected(ctx: AuthContext = app.auth()):
|
|
16
|
+
return {"tenant": ctx.tenant_id, "user": ctx.user_id, "roles": list(ctx.roles)}
|
|
17
|
+
|
|
18
|
+
@app.get("/farmer-only")
|
|
19
|
+
async def farmer_only(ctx: AuthContext = app.auth(roles=["Farmer"])):
|
|
20
|
+
return {"ok": True}
|
|
21
|
+
|
|
22
|
+
return TestClient(app)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_health_no_auth() -> None:
|
|
26
|
+
c = make_client()
|
|
27
|
+
r = c.get("/health")
|
|
28
|
+
assert r.status_code == 200
|
|
29
|
+
assert r.json() == {"status": "ok", "module": "testmod"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_ready_no_auth() -> None:
|
|
33
|
+
c = make_client()
|
|
34
|
+
r = c.get("/ready")
|
|
35
|
+
assert r.status_code == 200
|
|
36
|
+
assert r.json()["status"] == "ready"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_protected_requires_tenant_header() -> None:
|
|
40
|
+
c = make_client()
|
|
41
|
+
r = c.get("/protected")
|
|
42
|
+
assert r.status_code == 401
|
|
43
|
+
assert "X-Tenant-ID" in r.json()["detail"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_protected_requires_user_header() -> None:
|
|
47
|
+
c = make_client()
|
|
48
|
+
r = c.get("/protected", headers={"X-Tenant-ID": "acme"})
|
|
49
|
+
assert r.status_code == 401
|
|
50
|
+
assert "X-User-ID" in r.json()["detail"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_protected_rejects_invalid_tenant_format() -> None:
|
|
54
|
+
c = make_client()
|
|
55
|
+
r = c.get("/protected", headers={"X-Tenant-ID": "BadTenant!", "X-User-ID": "u"})
|
|
56
|
+
assert r.status_code == 401
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_protected_success() -> None:
|
|
60
|
+
c = make_client()
|
|
61
|
+
r = c.get(
|
|
62
|
+
"/protected",
|
|
63
|
+
headers={
|
|
64
|
+
"X-Tenant-ID": "acme",
|
|
65
|
+
"X-User-ID": "u-1",
|
|
66
|
+
"X-User-Roles": "Farmer,TenantAdmin",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
assert r.status_code == 200
|
|
70
|
+
body = r.json()
|
|
71
|
+
assert body["tenant"] == "acme"
|
|
72
|
+
assert body["user"] == "u-1"
|
|
73
|
+
assert body["roles"] == ["Farmer", "TenantAdmin"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_role_check_blocks_when_missing() -> None:
|
|
77
|
+
c = make_client()
|
|
78
|
+
r = c.get(
|
|
79
|
+
"/farmer-only",
|
|
80
|
+
headers={"X-Tenant-ID": "acme", "X-User-ID": "u", "X-User-Roles": "Random"},
|
|
81
|
+
)
|
|
82
|
+
assert r.status_code == 403
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_role_check_allows_when_present() -> None:
|
|
86
|
+
c = make_client()
|
|
87
|
+
r = c.get(
|
|
88
|
+
"/farmer-only",
|
|
89
|
+
headers={"X-Tenant-ID": "acme", "X-User-ID": "u", "X-User-Roles": "Farmer"},
|
|
90
|
+
)
|
|
91
|
+
assert r.status_code == 200
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_cors_origin_allowed_when_configured() -> None:
|
|
95
|
+
c = make_client(allowed_origins=["https://example.test"])
|
|
96
|
+
r = c.options(
|
|
97
|
+
"/protected",
|
|
98
|
+
headers={
|
|
99
|
+
"Origin": "https://example.test",
|
|
100
|
+
"Access-Control-Request-Method": "GET",
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
assert r.status_code == 200
|
|
104
|
+
assert r.headers.get("access-control-allow-origin") == "https://example.test"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_request_id_header_present_in_response() -> None:
|
|
108
|
+
c = make_client()
|
|
109
|
+
r = c.get("/health")
|
|
110
|
+
assert "x-request-id" in {k.lower() for k in r.headers}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_request_id_passthrough_when_provided() -> None:
|
|
114
|
+
c = make_client()
|
|
115
|
+
r = c.get("/health", headers={"X-Request-ID": "trace-abc"})
|
|
116
|
+
assert r.headers["x-request-id"] == "trace-abc"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_orion_factory_returns_scoped_client() -> None:
|
|
120
|
+
app = ModuleApp(id="testmod", configure_logging=False)
|
|
121
|
+
ctx = AuthContext(tenant_id="acme", user_id="u", roles=("Farmer",))
|
|
122
|
+
orion = app.orion(ctx)
|
|
123
|
+
assert orion.tenant_id == "acme"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_unhandled_exception_returns_500_json() -> None:
|
|
127
|
+
app = ModuleApp(id="testmod", configure_logging=False)
|
|
128
|
+
|
|
129
|
+
@app.get("/boom")
|
|
130
|
+
async def boom() -> None:
|
|
131
|
+
raise RuntimeError("oops")
|
|
132
|
+
|
|
133
|
+
c = TestClient(app, raise_server_exceptions=False)
|
|
134
|
+
r = c.get("/boom")
|
|
135
|
+
assert r.status_code == 500
|
|
136
|
+
assert r.json()["error"] == "internal"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_logging_filter_does_not_break_when_enabled() -> None:
|
|
140
|
+
# Just construct with logging on, ensure no exceptions
|
|
141
|
+
ModuleApp(id="testmod", configure_logging=True)
|
|
142
|
+
logging.getLogger("nkz.access").info("smoke")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Unit tests for TimescaleClient — fakes the httpx layer, no network."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from nkz_platform_sdk.timescale import TimescaleClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _FakeResponse:
|
|
11
|
+
def __init__(self, status_code: int, payload: Any):
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self._payload = payload
|
|
14
|
+
|
|
15
|
+
def raise_for_status(self) -> None:
|
|
16
|
+
if self.status_code >= 400:
|
|
17
|
+
raise RuntimeError(f"HTTP {self.status_code}")
|
|
18
|
+
|
|
19
|
+
def json(self) -> Any:
|
|
20
|
+
return self._payload
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _FakeAsyncClient:
|
|
24
|
+
def __init__(self, response: _FakeResponse):
|
|
25
|
+
self.response = response
|
|
26
|
+
self.last_get: dict[str, Any] | None = None
|
|
27
|
+
self.last_post: dict[str, Any] | None = None
|
|
28
|
+
|
|
29
|
+
async def get(self, url: str, params: dict[str, Any], headers: dict[str, str]):
|
|
30
|
+
self.last_get = {"url": url, "params": params, "headers": headers}
|
|
31
|
+
return self.response
|
|
32
|
+
|
|
33
|
+
async def post(self, url: str, json: dict[str, Any], headers: dict[str, str]):
|
|
34
|
+
self.last_post = {"url": url, "json": json, "headers": headers}
|
|
35
|
+
return self.response
|
|
36
|
+
|
|
37
|
+
async def aclose(self) -> None:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _make(payload: Any, status: int = 200) -> tuple[TimescaleClient, _FakeAsyncClient]:
|
|
42
|
+
client = TimescaleClient(tenant_id="acme", base_url="http://ts-test:5000")
|
|
43
|
+
fake = _FakeAsyncClient(_FakeResponse(status, payload))
|
|
44
|
+
client._client = fake # type: ignore[assignment]
|
|
45
|
+
return client, fake
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@pytest.mark.asyncio
|
|
49
|
+
async def test_query_normalises_points() -> None:
|
|
50
|
+
client, fake = _make({"points": [{"ts": "2026-05-01T00:00:00Z", "value": "42.5"}]})
|
|
51
|
+
pts = await client.query(
|
|
52
|
+
entity_id="urn:ngsi-ld:AgriParcel:p1",
|
|
53
|
+
attribute="soilMoisture",
|
|
54
|
+
since="2026-05-01T00:00:00Z",
|
|
55
|
+
)
|
|
56
|
+
assert pts == [{"ts": "2026-05-01T00:00:00Z", "value": 42.5}]
|
|
57
|
+
assert fake.last_get is not None
|
|
58
|
+
assert fake.last_get["headers"]["X-Tenant-ID"] == "acme"
|
|
59
|
+
assert fake.last_get["headers"]["NGSILD-Tenant"] == "acme"
|
|
60
|
+
assert fake.last_get["headers"]["Fiware-Service"] == "acme"
|
|
61
|
+
assert fake.last_get["params"]["entityId"] == "urn:ngsi-ld:AgriParcel:p1"
|
|
62
|
+
assert fake.last_get["params"]["attribute"] == "soilMoisture"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_query_filters_non_numeric() -> None:
|
|
67
|
+
client, _ = _make(
|
|
68
|
+
{
|
|
69
|
+
"points": [
|
|
70
|
+
{"ts": "2026-05-01T00:00:00Z", "value": "42"},
|
|
71
|
+
{"ts": "2026-05-02T00:00:00Z", "value": "not-a-number"},
|
|
72
|
+
{"ts": "2026-05-03T00:00:00Z"}, # missing value
|
|
73
|
+
{"ts": "2026-05-04T00:00:00Z", "value": 7},
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
pts = await client.query(
|
|
78
|
+
entity_id="urn:ngsi-ld:AgriParcel:p1",
|
|
79
|
+
attribute="x",
|
|
80
|
+
since="2026-05-01T00:00:00Z",
|
|
81
|
+
)
|
|
82
|
+
assert [p["value"] for p in pts] == [42.0, 7.0]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_query_accepts_bare_list_payload() -> None:
|
|
87
|
+
client, _ = _make([{"ts": "2026-05-01T00:00:00Z", "value": 1}])
|
|
88
|
+
pts = await client.query(
|
|
89
|
+
entity_id="urn:ngsi-ld:AgriParcel:p1",
|
|
90
|
+
attribute="x",
|
|
91
|
+
since="2026-05-01T00:00:00Z",
|
|
92
|
+
)
|
|
93
|
+
assert pts == [{"ts": "2026-05-01T00:00:00Z", "value": 1.0}]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_query_aggregate_posts_to_v2() -> None:
|
|
98
|
+
client, fake = _make({"series": []})
|
|
99
|
+
body = {"entityIds": ["a", "b"], "attributes": ["x"], "aggrPeriod": "PT1H"}
|
|
100
|
+
out = await client.query_aggregate(body)
|
|
101
|
+
assert out == {"series": []}
|
|
102
|
+
assert fake.last_post is not None
|
|
103
|
+
assert fake.last_post["url"].endswith("/api/v2/query")
|
|
104
|
+
assert fake.last_post["json"] == body
|
|
105
|
+
assert fake.last_post["headers"]["X-Tenant-ID"] == "acme"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@pytest.mark.asyncio
|
|
109
|
+
async def test_http_error_propagates() -> None:
|
|
110
|
+
client, _ = _make({}, status=500)
|
|
111
|
+
with pytest.raises(RuntimeError):
|
|
112
|
+
await client.query(
|
|
113
|
+
entity_id="urn:ngsi-ld:AgriParcel:p1",
|
|
114
|
+
attribute="x",
|
|
115
|
+
since="2026-05-01T00:00:00Z",
|
|
116
|
+
)
|