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.
@@ -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,8 @@
1
+ fastapi>=0.109.0
2
+ httpx>=0.26.0
3
+ python-jose[cryptography]>=3.3.0
4
+ cryptography>=42.0.0
5
+
6
+ [dev]
7
+ pytest>=7.4.0
8
+ pytest-asyncio>=0.23.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )