ngits-drf-iot-client 0.1.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.
- ngits_drf_iot_client-0.1.0/PKG-INFO +85 -0
- ngits_drf_iot_client-0.1.0/README.md +66 -0
- ngits_drf_iot_client-0.1.0/iot_client/__init__.py +28 -0
- ngits_drf_iot_client-0.1.0/iot_client/apps.py +12 -0
- ngits_drf_iot_client-0.1.0/iot_client/client.py +87 -0
- ngits_drf_iot_client-0.1.0/iot_client/conf.py +26 -0
- ngits_drf_iot_client-0.1.0/iot_client/exceptions.py +13 -0
- ngits_drf_iot_client-0.1.0/iot_client/identity.py +25 -0
- ngits_drf_iot_client-0.1.0/iot_client/mixins.py +42 -0
- ngits_drf_iot_client-0.1.0/iot_client/proxy.py +110 -0
- ngits_drf_iot_client-0.1.0/iot_client/resources/__init__.py +13 -0
- ngits_drf_iot_client-0.1.0/iot_client/resources/alerts.py +41 -0
- ngits_drf_iot_client-0.1.0/iot_client/resources/automations.py +71 -0
- ngits_drf_iot_client-0.1.0/iot_client/resources/base.py +29 -0
- ngits_drf_iot_client-0.1.0/iot_client/resources/commands.py +15 -0
- ngits_drf_iot_client-0.1.0/iot_client/schemas.py +255 -0
- ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/PKG-INFO +85 -0
- ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/SOURCES.txt +23 -0
- ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/dependency_links.txt +1 -0
- ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/requires.txt +12 -0
- ngits_drf_iot_client-0.1.0/ngits_drf_iot_client.egg-info/top_level.txt +1 -0
- ngits_drf_iot_client-0.1.0/pyproject.toml +38 -0
- ngits_drf_iot_client-0.1.0/setup.cfg +4 -0
- ngits_drf_iot_client-0.1.0/tests/test_client.py +215 -0
- ngits_drf_iot_client-0.1.0/tests/test_schemas.py +80 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ngits-drf-iot-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Identity-agnostic Django/DRF client for the NGITS IoT ESB automation & command APIs
|
|
5
|
+
Author-email: "NG IT Services Sp. z o.o." <biuro@ngits.pl>
|
|
6
|
+
License: BSD-3-Clause
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: Django>=4.2
|
|
10
|
+
Requires-Dist: djangorestframework>=3.14
|
|
11
|
+
Requires-Dist: requests>=2.31
|
|
12
|
+
Provides-Extra: schemas
|
|
13
|
+
Requires-Dist: drf-spectacular>=0.27; extra == "schemas"
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
17
|
+
Requires-Dist: isort>=5.13.0; extra == "dev"
|
|
18
|
+
Requires-Dist: drf-spectacular>=0.27; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# ngits-drf-iot-client
|
|
21
|
+
|
|
22
|
+
Identity-agnostic Django/DRF client for the **IoT ESB** automation and command
|
|
23
|
+
APIs. It lets any application (a single-tenant app, a multi-tenant panel, ...)
|
|
24
|
+
act as a thin BFF over the shared IoT platform **without duplicating domain
|
|
25
|
+
logic** — the same way `ngits-drf-cube-proxy` sits in front of analytics.
|
|
26
|
+
|
|
27
|
+
The automation domain (models, evaluation, command dispatch) lives in the IoT
|
|
28
|
+
platform services. This package is only the transport + tenant-scoping glue
|
|
29
|
+
used by the presentation layer.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install ngits-drf-iot-client
|
|
35
|
+
# during local development, before it is published:
|
|
36
|
+
pip install -e ../ngits-drf-iot-client
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configure (host `settings.py`)
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
IOT_ESB_BASE_URL = "https://iot-esb.example.com" # client appends /api/v1
|
|
43
|
+
IOT_ESB_TOKEN = env("IOT_ESB_TOKEN") # sent as X-Token
|
|
44
|
+
IOT_CLIENT_IDENTITY_RESOLVER = "myapp.iot.resolve_scope"
|
|
45
|
+
# optional:
|
|
46
|
+
IOT_CLIENT_TIMEOUT = 10
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The resolver maps an authenticated user to the tenant(s) they may access:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
# myapp/iot.py (single-tenant app)
|
|
53
|
+
def resolve_scope(user):
|
|
54
|
+
tenant = get_tenant_for(user) # your own lookup
|
|
55
|
+
return None if tenant is None else tenant.uuid # None -> 403
|
|
56
|
+
|
|
57
|
+
# multi-tenant panel
|
|
58
|
+
def resolve_scope(user):
|
|
59
|
+
return list(get_tenants_for(user)) # iterable of tenant UUIDs
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Use in a DRF view
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from rest_framework import viewsets
|
|
66
|
+
from rest_framework.response import Response
|
|
67
|
+
from iot_client import IotClientMixin
|
|
68
|
+
|
|
69
|
+
class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
|
|
70
|
+
def list(self, request):
|
|
71
|
+
client = self.get_iot_client()
|
|
72
|
+
return Response(client.list_automations(tenants=self.get_tenant_scope()))
|
|
73
|
+
|
|
74
|
+
def create(self, request):
|
|
75
|
+
client = self.get_iot_client()
|
|
76
|
+
return Response(client.create_automation(request.data), status=201)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Client surface
|
|
80
|
+
|
|
81
|
+
`IotEsbClient`: `list_automations`, `get_automation`, `create_automation`,
|
|
82
|
+
`update_automation`, `delete_automation`, `activate_automation`,
|
|
83
|
+
`deactivate_automation`, `list_rule_types`, `list_actions`, `create_command`,
|
|
84
|
+
`get_command`. All read methods accept a `tenants` scope; errors raise
|
|
85
|
+
`IotClientError(status_code=...)`.
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# ngits-drf-iot-client
|
|
2
|
+
|
|
3
|
+
Identity-agnostic Django/DRF client for the **IoT ESB** automation and command
|
|
4
|
+
APIs. It lets any application (a single-tenant app, a multi-tenant panel, ...)
|
|
5
|
+
act as a thin BFF over the shared IoT platform **without duplicating domain
|
|
6
|
+
logic** — the same way `ngits-drf-cube-proxy` sits in front of analytics.
|
|
7
|
+
|
|
8
|
+
The automation domain (models, evaluation, command dispatch) lives in the IoT
|
|
9
|
+
platform services. This package is only the transport + tenant-scoping glue
|
|
10
|
+
used by the presentation layer.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install ngits-drf-iot-client
|
|
16
|
+
# during local development, before it is published:
|
|
17
|
+
pip install -e ../ngits-drf-iot-client
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Configure (host `settings.py`)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
IOT_ESB_BASE_URL = "https://iot-esb.example.com" # client appends /api/v1
|
|
24
|
+
IOT_ESB_TOKEN = env("IOT_ESB_TOKEN") # sent as X-Token
|
|
25
|
+
IOT_CLIENT_IDENTITY_RESOLVER = "myapp.iot.resolve_scope"
|
|
26
|
+
# optional:
|
|
27
|
+
IOT_CLIENT_TIMEOUT = 10
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The resolver maps an authenticated user to the tenant(s) they may access:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
# myapp/iot.py (single-tenant app)
|
|
34
|
+
def resolve_scope(user):
|
|
35
|
+
tenant = get_tenant_for(user) # your own lookup
|
|
36
|
+
return None if tenant is None else tenant.uuid # None -> 403
|
|
37
|
+
|
|
38
|
+
# multi-tenant panel
|
|
39
|
+
def resolve_scope(user):
|
|
40
|
+
return list(get_tenants_for(user)) # iterable of tenant UUIDs
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Use in a DRF view
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from rest_framework import viewsets
|
|
47
|
+
from rest_framework.response import Response
|
|
48
|
+
from iot_client import IotClientMixin
|
|
49
|
+
|
|
50
|
+
class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
|
|
51
|
+
def list(self, request):
|
|
52
|
+
client = self.get_iot_client()
|
|
53
|
+
return Response(client.list_automations(tenants=self.get_tenant_scope()))
|
|
54
|
+
|
|
55
|
+
def create(self, request):
|
|
56
|
+
client = self.get_iot_client()
|
|
57
|
+
return Response(client.create_automation(request.data), status=201)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Client surface
|
|
61
|
+
|
|
62
|
+
`IotEsbClient`: `list_automations`, `get_automation`, `create_automation`,
|
|
63
|
+
`update_automation`, `delete_automation`, `activate_automation`,
|
|
64
|
+
`deactivate_automation`, `list_rule_types`, `list_actions`, `create_command`,
|
|
65
|
+
`get_command`. All read methods accept a `tenants` scope; errors raise
|
|
66
|
+
`IotClientError(status_code=...)`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""NGITS DRF IoT client.
|
|
2
|
+
|
|
3
|
+
A thin, identity-agnostic client + DRF helpers for the IoT ESB automation,
|
|
4
|
+
command and alert APIs. The client exposes one sub-client per resource
|
|
5
|
+
(``client.automations`` / ``client.commands`` / ``client.alerts``), and
|
|
6
|
+
``IotResourceProxyViewSet`` turns a host module into a thin BFF. Mirrors
|
|
7
|
+
``ngits-drf-cube-proxy``: applications (a single-tenant app, a multi-tenant
|
|
8
|
+
panel, ...) install this package and wire their tenant-scope resolver via
|
|
9
|
+
``IOT_CLIENT_IDENTITY_RESOLVER`` — the package itself knows nothing about a
|
|
10
|
+
particular application's identity model.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from iot_client.client import IotEsbClient
|
|
14
|
+
from iot_client.exceptions import IotClientError, IotClientImproperlyConfigured
|
|
15
|
+
from iot_client.identity import resolve_scope
|
|
16
|
+
from iot_client.mixins import IotClientMixin
|
|
17
|
+
from iot_client.proxy import IotResourceProxyViewSet
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"IotEsbClient",
|
|
23
|
+
"IotClientError",
|
|
24
|
+
"IotClientImproperlyConfigured",
|
|
25
|
+
"resolve_scope",
|
|
26
|
+
"IotClientMixin",
|
|
27
|
+
"IotResourceProxyViewSet",
|
|
28
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Optional Django AppConfig.
|
|
2
|
+
|
|
3
|
+
The client works as a plain library (just reads settings), but it can be added
|
|
4
|
+
to ``INSTALLED_APPS`` for discoverability and future checks/management commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from django.apps import AppConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class IotClientConfig(AppConfig):
|
|
11
|
+
name = "iot_client"
|
|
12
|
+
verbose_name = "NGITS IoT ESB client"
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""HTTP transport for the IoT ESB, exposing one sub-client per resource.
|
|
2
|
+
|
|
3
|
+
Pure transport: no Django request/user awareness (that lives in
|
|
4
|
+
``iot_client.identity`` / ``iot_client.mixins`` / ``iot_client.proxy``), so it is
|
|
5
|
+
trivially unit-testable. Resources are reached as namespaced sub-clients::
|
|
6
|
+
|
|
7
|
+
client = IotEsbClient()
|
|
8
|
+
client.automations.list(tenants=...)
|
|
9
|
+
client.commands.create(payload)
|
|
10
|
+
client.alerts.list(tenants=...)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from functools import cached_property
|
|
14
|
+
from typing import Any, Dict, Optional
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from iot_client.conf import get_setting
|
|
19
|
+
from iot_client.exceptions import IotClientError
|
|
20
|
+
from iot_client.resources import (
|
|
21
|
+
AlertsResource,
|
|
22
|
+
AutomationsResource,
|
|
23
|
+
CommandsResource,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class IotEsbClient:
|
|
28
|
+
"""Client for ``{IOT_ESB_BASE_URL}/api/v1`` with per-resource sub-clients."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
base_url: Optional[str] = None,
|
|
33
|
+
token: Optional[str] = None,
|
|
34
|
+
timeout: Optional[int] = None,
|
|
35
|
+
):
|
|
36
|
+
self.base_url = (base_url or get_setting("IOT_ESB_BASE_URL")).rstrip("/")
|
|
37
|
+
self.token = token or get_setting("IOT_ESB_TOKEN")
|
|
38
|
+
self.timeout = timeout or get_setting("IOT_CLIENT_TIMEOUT", 10)
|
|
39
|
+
|
|
40
|
+
# -- resource sub-clients ---------------------------------------------
|
|
41
|
+
|
|
42
|
+
@cached_property
|
|
43
|
+
def automations(self) -> AutomationsResource:
|
|
44
|
+
return AutomationsResource(self)
|
|
45
|
+
|
|
46
|
+
@cached_property
|
|
47
|
+
def commands(self) -> CommandsResource:
|
|
48
|
+
return CommandsResource(self)
|
|
49
|
+
|
|
50
|
+
@cached_property
|
|
51
|
+
def alerts(self) -> AlertsResource:
|
|
52
|
+
return AlertsResource(self)
|
|
53
|
+
|
|
54
|
+
# -- transport ---------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def _headers(self) -> Dict[str, str]:
|
|
58
|
+
return {"Content-Type": "application/json", "X-Token": str(self.token)}
|
|
59
|
+
|
|
60
|
+
def _url(self, path: str) -> str:
|
|
61
|
+
return f"{self.base_url}/api/v1{path}"
|
|
62
|
+
|
|
63
|
+
def _request(
|
|
64
|
+
self,
|
|
65
|
+
method: str,
|
|
66
|
+
path: str,
|
|
67
|
+
*,
|
|
68
|
+
params: Optional[Dict[str, Any]] = None,
|
|
69
|
+
json: Optional[Dict[str, Any]] = None,
|
|
70
|
+
) -> Any:
|
|
71
|
+
try:
|
|
72
|
+
response = requests.request(
|
|
73
|
+
method,
|
|
74
|
+
self._url(path),
|
|
75
|
+
params=params,
|
|
76
|
+
json=json,
|
|
77
|
+
headers=self._headers,
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
)
|
|
80
|
+
except requests.RequestException as exc:
|
|
81
|
+
raise IotClientError(f"IoT ESB request failed: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
if response.status_code >= 400:
|
|
84
|
+
raise IotClientError(response.text, response.status_code)
|
|
85
|
+
if response.status_code == 204 or not response.content:
|
|
86
|
+
return None
|
|
87
|
+
return response.json()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Settings access for the IoT client.
|
|
2
|
+
|
|
3
|
+
Recognized Django settings:
|
|
4
|
+
- ``IOT_ESB_BASE_URL`` (required) base URL of the IoT ESB, e.g.
|
|
5
|
+
``https://iot-esb.example.com``. The client appends ``/api/v1``.
|
|
6
|
+
- ``IOT_ESB_TOKEN`` (required) token sent in the ``X-Token`` header.
|
|
7
|
+
- ``IOT_CLIENT_IDENTITY_RESOLVER`` (optional) dotted path to a callable
|
|
8
|
+
``resolver(user) -> tenant scope`` (see ``iot_client.identity``).
|
|
9
|
+
- ``IOT_CLIENT_TIMEOUT`` (optional, default 10) request timeout in seconds.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from django.conf import settings
|
|
13
|
+
|
|
14
|
+
from iot_client.exceptions import IotClientImproperlyConfigured
|
|
15
|
+
|
|
16
|
+
_SENTINEL = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_setting(name: str, default=_SENTINEL):
|
|
20
|
+
"""Return a Django setting, or raise if required and missing."""
|
|
21
|
+
value = getattr(settings, name, _SENTINEL)
|
|
22
|
+
if value is _SENTINEL:
|
|
23
|
+
if default is _SENTINEL:
|
|
24
|
+
raise IotClientImproperlyConfigured(f"Missing required setting: {name}")
|
|
25
|
+
return default
|
|
26
|
+
return value
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Exceptions raised by the IoT client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class IotClientError(Exception):
|
|
5
|
+
"""An error returned by (or while reaching) the IoT ESB API."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, message: str, status_code: int = 0):
|
|
8
|
+
super().__init__(message)
|
|
9
|
+
self.status_code = status_code
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IotClientImproperlyConfigured(Exception):
|
|
13
|
+
"""A required Django setting for the IoT client is missing."""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Tenant-scope resolution.
|
|
2
|
+
|
|
3
|
+
The package is identity-agnostic: the host application supplies a callable via
|
|
4
|
+
``IOT_CLIENT_IDENTITY_RESOLVER`` (a dotted path) that maps an authenticated user
|
|
5
|
+
to the tenant(s) they may access. The resolver returns:
|
|
6
|
+
|
|
7
|
+
- ``None`` -> no access (callers should treat as 403),
|
|
8
|
+
- a single ``UUID``/str -> one tenant (e.g. a single-tenant app),
|
|
9
|
+
- an iterable of ids -> several tenants (e.g. a multi-tenant panel).
|
|
10
|
+
|
|
11
|
+
This mirrors ``ngits-drf-cube-proxy``'s ``CUBE_PROXY_IDENTITY_RESOLVER``.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from django.utils.module_loading import import_string
|
|
15
|
+
|
|
16
|
+
from iot_client.conf import get_setting
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def resolve_scope(user):
|
|
20
|
+
"""Resolve the tenant scope for ``user`` using the configured resolver."""
|
|
21
|
+
dotted_path = get_setting("IOT_CLIENT_IDENTITY_RESOLVER", None)
|
|
22
|
+
if not dotted_path:
|
|
23
|
+
return None
|
|
24
|
+
resolver = import_string(dotted_path)
|
|
25
|
+
return resolver(user)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""DRF helpers for building a thin BFF over the IoT ESB.
|
|
2
|
+
|
|
3
|
+
Usage in a host application::
|
|
4
|
+
|
|
5
|
+
class AutomationViewSet(IotClientMixin, viewsets.ViewSet):
|
|
6
|
+
def list(self, request):
|
|
7
|
+
client = self.get_iot_client()
|
|
8
|
+
return Response(client.list_automations(tenants=self.get_tenant_scope()))
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
from rest_framework.exceptions import PermissionDenied
|
|
14
|
+
|
|
15
|
+
from iot_client.client import IotEsbClient
|
|
16
|
+
from iot_client.identity import resolve_scope
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class IotClientMixin:
|
|
20
|
+
"""Provides an ESB client and the request user's tenant scope."""
|
|
21
|
+
|
|
22
|
+
iot_client_class = IotEsbClient
|
|
23
|
+
|
|
24
|
+
def get_iot_client(self) -> IotEsbClient:
|
|
25
|
+
return self.iot_client_class()
|
|
26
|
+
|
|
27
|
+
def get_tenant_scope(self) -> List:
|
|
28
|
+
"""Return the user's tenant scope as a list, or raise 403.
|
|
29
|
+
|
|
30
|
+
The host application's resolver (``IOT_CLIENT_IDENTITY_RESOLVER``)
|
|
31
|
+
decides what each user may see; a single-tenant app returns one id, a
|
|
32
|
+
multi-tenant panel returns several.
|
|
33
|
+
"""
|
|
34
|
+
scope = resolve_scope(self.request.user)
|
|
35
|
+
if scope is None:
|
|
36
|
+
raise PermissionDenied("No IoT tenant scope for this user.")
|
|
37
|
+
if isinstance(scope, (list, tuple, set)):
|
|
38
|
+
scope_list = list(scope)
|
|
39
|
+
if not scope_list:
|
|
40
|
+
raise PermissionDenied("Empty IoT tenant scope for this user.")
|
|
41
|
+
return scope_list
|
|
42
|
+
return [scope]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Generic DRF proxy viewset over an IoT ESB resource sub-client.
|
|
2
|
+
|
|
3
|
+
Turns a host-application module into a thin BFF: subclass, set ``resource_name``
|
|
4
|
+
to a sub-client attribute on :class:`~iot_client.client.IotEsbClient` (e.g.
|
|
5
|
+
``"automations"``), and the standard list / retrieve / create / partial_update /
|
|
6
|
+
destroy actions are wired — tenant-scoped, with ESB errors mapped to HTTP. Add
|
|
7
|
+
``@action`` methods for resource-specific extras (e.g. activate, catalog).
|
|
8
|
+
|
|
9
|
+
Example::
|
|
10
|
+
|
|
11
|
+
from rest_framework.decorators import action
|
|
12
|
+
from rest_framework.response import Response
|
|
13
|
+
from iot_client import IotResourceProxyViewSet
|
|
14
|
+
|
|
15
|
+
class AutomationViewSet(IotResourceProxyViewSet):
|
|
16
|
+
resource_name = "automations"
|
|
17
|
+
lookup_value_regex = "[0-9a-f-]{36}"
|
|
18
|
+
|
|
19
|
+
@action(detail=True, methods=["post"])
|
|
20
|
+
def activate(self, request, pk=None):
|
|
21
|
+
return self.proxy(lambda r: r.activate(pk, tenants=self.get_tenant_scope()))
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from rest_framework import status, viewsets
|
|
25
|
+
from rest_framework.response import Response
|
|
26
|
+
|
|
27
|
+
from iot_client.exceptions import IotClientError
|
|
28
|
+
from iot_client.mixins import IotClientMixin
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class IotResourceProxyViewSet(IotClientMixin, viewsets.ViewSet):
|
|
32
|
+
"""Standard CRUD proxy to ``IotEsbClient.<resource_name>``."""
|
|
33
|
+
|
|
34
|
+
#: attribute name of the sub-client on IotEsbClient, e.g. "automations".
|
|
35
|
+
resource_name: str = None
|
|
36
|
+
#: optional DRF serializers used to validate create / update bodies.
|
|
37
|
+
create_serializer_class = None
|
|
38
|
+
update_serializer_class = None
|
|
39
|
+
|
|
40
|
+
def get_resource(self):
|
|
41
|
+
if not self.resource_name:
|
|
42
|
+
raise NotImplementedError("Set `resource_name` on the viewset.")
|
|
43
|
+
return getattr(self.get_iot_client(), self.resource_name)
|
|
44
|
+
|
|
45
|
+
# -- hooks -------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
def get_list_filters(self) -> dict:
|
|
48
|
+
"""Extra query filters for ``list`` (override per module)."""
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
def prepare_create_payload(self, data: dict) -> dict:
|
|
52
|
+
"""Adapt the validated/raw create body before sending (override)."""
|
|
53
|
+
return dict(data)
|
|
54
|
+
|
|
55
|
+
# -- standard actions --------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def list(self, request):
|
|
58
|
+
return self.proxy(
|
|
59
|
+
lambda r: r.list(tenants=self.get_tenant_scope(), **self.get_list_filters())
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def retrieve(self, request, pk=None):
|
|
63
|
+
return self.proxy(lambda r: r.get(pk, tenants=self.get_tenant_scope()))
|
|
64
|
+
|
|
65
|
+
def create(self, request):
|
|
66
|
+
payload = self.prepare_create_payload(
|
|
67
|
+
self._validate(self.create_serializer_class, request.data)
|
|
68
|
+
)
|
|
69
|
+
return self.proxy(lambda r: r.create(payload), success=status.HTTP_201_CREATED)
|
|
70
|
+
|
|
71
|
+
def partial_update(self, request, pk=None):
|
|
72
|
+
payload = self._validate(self.update_serializer_class, request.data)
|
|
73
|
+
return self.proxy(
|
|
74
|
+
lambda r: r.update(pk, payload, tenants=self.get_tenant_scope())
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def destroy(self, request, pk=None):
|
|
78
|
+
return self.proxy(
|
|
79
|
+
lambda r: r.delete(pk, tenants=self.get_tenant_scope()),
|
|
80
|
+
success=status.HTTP_204_NO_CONTENT,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# -- helpers -----------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def proxy(self, call, success=status.HTTP_200_OK) -> Response:
|
|
86
|
+
"""Run a sub-client call, mapping ``IotClientError`` to an HTTP error."""
|
|
87
|
+
try:
|
|
88
|
+
result = call(self.get_resource())
|
|
89
|
+
except IotClientError as exc:
|
|
90
|
+
return self._esb_error(exc)
|
|
91
|
+
if success == status.HTTP_204_NO_CONTENT:
|
|
92
|
+
return Response(status=success)
|
|
93
|
+
return Response(result, status=success)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _validate(serializer_class, data) -> dict:
|
|
97
|
+
if serializer_class is None:
|
|
98
|
+
return dict(data)
|
|
99
|
+
serializer = serializer_class(data=data)
|
|
100
|
+
serializer.is_valid(raise_exception=True)
|
|
101
|
+
return serializer.validated_data
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _esb_error(exc: IotClientError) -> Response:
|
|
105
|
+
code = (
|
|
106
|
+
status.HTTP_404_NOT_FOUND
|
|
107
|
+
if exc.status_code == 404
|
|
108
|
+
else status.HTTP_502_BAD_GATEWAY
|
|
109
|
+
)
|
|
110
|
+
return Response({"detail": str(exc)}, status=code)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""ESB resource sub-clients."""
|
|
2
|
+
|
|
3
|
+
from iot_client.resources.alerts import AlertsResource
|
|
4
|
+
from iot_client.resources.automations import AutomationsResource
|
|
5
|
+
from iot_client.resources.base import BaseResource
|
|
6
|
+
from iot_client.resources.commands import CommandsResource
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseResource",
|
|
10
|
+
"AlertsResource",
|
|
11
|
+
"AutomationsResource",
|
|
12
|
+
"CommandsResource",
|
|
13
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Alert-definition resource sub-client (``/api/v1/alerts/definitions``).
|
|
2
|
+
|
|
3
|
+
The ESB alert-definitions endpoint scopes list by a single ``tenant`` query
|
|
4
|
+
param, so this resource uses the first tenant of the scope. Cross-tenant
|
|
5
|
+
enumeration (admin "list all") is the host application's responsibility — it owns
|
|
6
|
+
the tenant catalog — so it loops :meth:`list` over the tenant ids it resolves.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from iot_client.resources.base import BaseResource, TenantScope
|
|
12
|
+
|
|
13
|
+
_BASE = "/alerts/definitions"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AlertsResource(BaseResource):
|
|
17
|
+
"""CRUD for alert definitions."""
|
|
18
|
+
|
|
19
|
+
def list(
|
|
20
|
+
self, tenants: TenantScope = None, meter_id: Optional[str] = None
|
|
21
|
+
) -> List[Dict[str, Any]]:
|
|
22
|
+
params: Dict[str, Any] = {}
|
|
23
|
+
if tenants:
|
|
24
|
+
params["tenant"] = str(list(tenants)[0])
|
|
25
|
+
if meter_id:
|
|
26
|
+
params["meter_id"] = meter_id
|
|
27
|
+
return self._request("GET", _BASE, params=params)
|
|
28
|
+
|
|
29
|
+
def get(self, definition_id, tenants: TenantScope = None) -> Dict[str, Any]:
|
|
30
|
+
return self._request("GET", f"{_BASE}/{definition_id}")
|
|
31
|
+
|
|
32
|
+
def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
33
|
+
return self._request("POST", _BASE, json=payload)
|
|
34
|
+
|
|
35
|
+
def update(
|
|
36
|
+
self, definition_id, payload: Dict[str, Any], tenants: TenantScope = None
|
|
37
|
+
) -> Dict[str, Any]:
|
|
38
|
+
return self._request("PATCH", f"{_BASE}/{definition_id}", json=payload)
|
|
39
|
+
|
|
40
|
+
def delete(self, definition_id, tenants: TenantScope = None) -> None:
|
|
41
|
+
return self._request("DELETE", f"{_BASE}/{definition_id}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Automation resource sub-client (``/api/v1/automations``)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
from uuid import UUID
|
|
5
|
+
|
|
6
|
+
from iot_client.resources.base import BaseResource, TenantScope, scope_params
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AutomationsResource(BaseResource):
|
|
10
|
+
"""CRUD + lifecycle + wizard catalog for automations."""
|
|
11
|
+
|
|
12
|
+
# -- uniform CRUD (consumed by IotResourceProxyViewSet) ----------------
|
|
13
|
+
|
|
14
|
+
def list(self, tenants: TenantScope = None, **filters: Any) -> Dict[str, Any]:
|
|
15
|
+
params = {k: v for k, v in filters.items() if v is not None}
|
|
16
|
+
params.update(scope_params(tenants))
|
|
17
|
+
return self._request("GET", "/automations", params=params)
|
|
18
|
+
|
|
19
|
+
def get(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
|
|
20
|
+
return self._request(
|
|
21
|
+
"GET", f"/automations/{automation_id}", params=scope_params(tenants)
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
25
|
+
return self._request("POST", "/automations", json=payload)
|
|
26
|
+
|
|
27
|
+
def update(
|
|
28
|
+
self, automation_id, payload: Dict[str, Any], tenants: TenantScope = None
|
|
29
|
+
) -> Dict[str, Any]:
|
|
30
|
+
return self._request(
|
|
31
|
+
"PATCH",
|
|
32
|
+
f"/automations/{automation_id}",
|
|
33
|
+
params=scope_params(tenants),
|
|
34
|
+
json=payload,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def delete(self, automation_id, tenants: TenantScope = None) -> None:
|
|
38
|
+
return self._request(
|
|
39
|
+
"DELETE",
|
|
40
|
+
f"/automations/{automation_id}",
|
|
41
|
+
params=scope_params(tenants),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# -- lifecycle ---------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def activate(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
|
|
47
|
+
return self._request(
|
|
48
|
+
"POST",
|
|
49
|
+
f"/automations/{automation_id}/activate",
|
|
50
|
+
params=scope_params(tenants),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def deactivate(self, automation_id, tenants: TenantScope = None) -> Dict[str, Any]:
|
|
54
|
+
return self._request(
|
|
55
|
+
"POST",
|
|
56
|
+
f"/automations/{automation_id}/deactivate",
|
|
57
|
+
params=scope_params(tenants),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# -- wizard catalog ----------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def rule_types(self) -> List[Dict[str, Any]]:
|
|
63
|
+
return self._request("GET", "/automations/catalog/rule-types")
|
|
64
|
+
|
|
65
|
+
def actions(
|
|
66
|
+
self, tenant: UUID, device_id: Optional[str] = None
|
|
67
|
+
) -> List[Dict[str, Any]]:
|
|
68
|
+
params: Dict[str, Any] = {"tenant": str(tenant)}
|
|
69
|
+
if device_id:
|
|
70
|
+
params["device_id"] = device_id
|
|
71
|
+
return self._request("GET", "/automations/catalog/actions", params=params)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Base class and helpers for ESB resource sub-clients.
|
|
2
|
+
|
|
3
|
+
A resource sub-client groups the operations of one ESB resource (automations,
|
|
4
|
+
commands, alert definitions, ...) and shares the transport of its parent
|
|
5
|
+
:class:`~iot_client.client.IotEsbClient`. Adding a new ESB resource = a new
|
|
6
|
+
``BaseResource`` subclass + a property on the client; nothing else changes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any, Dict, Optional, Sequence
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
TenantScope = Optional[Sequence[UUID]]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def scope_params(tenants: TenantScope) -> Dict[str, Any]:
|
|
16
|
+
"""Render a tenant scope as repeatable ``tenants`` query params."""
|
|
17
|
+
if not tenants:
|
|
18
|
+
return {}
|
|
19
|
+
return {"tenants": [str(t) for t in tenants]}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BaseResource:
|
|
23
|
+
"""Holds a reference to the client and delegates HTTP to its transport."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, client):
|
|
26
|
+
self._client = client
|
|
27
|
+
|
|
28
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
29
|
+
return self._client._request(method, path, **kwargs)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Command resource sub-client (``/api/v1/iot/commands``)."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
|
|
5
|
+
from iot_client.resources.base import BaseResource
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CommandsResource(BaseResource):
|
|
9
|
+
"""Device command dispatch + status."""
|
|
10
|
+
|
|
11
|
+
def create(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
12
|
+
return self._request("POST", "/iot/commands", json=payload)
|
|
13
|
+
|
|
14
|
+
def get(self, command_id) -> Dict[str, Any]:
|
|
15
|
+
return self._request("GET", f"/iot/commands/{command_id}")
|