Skip to content

Commit 18f5c43

Browse files
authored
Release/1.0.2 (#9)
* Refactoring base classes; new parser and parser tests. * Refactoring base classes; New base classes for resources and objects. * Base class refactoring #3. * Proper session handling; added unit test cases. * Fixed handling of empty results for get/post/put. * Improved header preparation/handling. * Added general integration testing fixture; Added .env file support. * Base class refactoring; new structure for SimpleObject and ComplexObject and their respective parsers; Added base classes unit tests. * Model base class improvements; Updated Measurement object model and tests. * Model base class improvements. * Module structure improvements; Model base class improvements - generic CRUD functions support additional resource spec; Fixed parser: False boolean fields were skipped always; User/Users implementation cleanup + base class adjustments; Adding integration tests for User/Users; Adding unit tests for User class; Added common test utils. * Updated measurements API; Fixed select by series; Added integration/unit tests; Fixed on-creation definition of fragments for Devices/ManagedObjects. * Default accept header can now be removed; Base API improvements + documentation. * Added application key header tests. * Base model/parsing improvements; Consolidation of ManagedObject, Device, Measurements. * Doc fixes. * Fixed device registry logging/initialization and added integration test cases. * Parsing improvements to prevent not updatable fields from being part of the full json. * Improved base class typing and handling of paths in derived classes; Cleansed GlobalRole model implementation; Adoption or User to new principles; Fixed factories for integration tests. * Added generic object factory for broader use in integration tests. * Added functionality to add/remove users and permissions from a global role. * Updated/improved Events API. * Fixed apply_to functionality. * Long due refactoring of json parse/format; updated documentation strings. * Updated/improved alarms API. * Added apply_by and count functions to Alarms API. * Documentation and cleanup. * Moved Identity API to separate file; Added test cases. * Fixed Binaries API; added tests. * Documentation update. * Added body/params to delete. * Fixed imports; Added devicegroups API. * Removed last_updated properties as it is already covered with update_time. * Added safe_executor. * Fixed handling of revert parameter. * Fixed devicegroups; added integration tests. * Fixed protected access, more consistent update handling. * Added documentation. * Moved ManagedObject classes to separate file. * Improved documentation. * Introducing Invoke; improved tag-based versioning. * Removed obsolete utils.py. * Fixed logging; fixed circular import from Identiy API. * Removed outdated samples. * Fixed applications. * Added encoding to file open statements. * Added argument hints. * Fixed documentation. * Fixed documentation. * Fixed inventory roles implementation. * Small documentation fixes; Added update_password functionality; Fixed inventory assignments. * Linting fixes. * Removed samples folder from linting; ignoring import errors. * Prepare release. * Issue/0007/cache bootstrap (#8) * Improved caching and user experience of CumulocityApp class; added corresponding unit tests. * Added changelog. * Added function to resolve the tenant ID from the authorization header. * Update CHANGELOG.md
1 parent 729d29a commit 18f5c43

File tree

6 files changed

+399
-55
lines changed

6 files changed

+399
-55
lines changed

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Changelog
2+
3+
4+
## Work in progress
5+
6+
## Version 1.0.2
7+
8+
### Changed
9+
10+
* Added this changelog :-)
11+
12+
* Fixed [Issue #7](https://github.com/SoftwareAG/cumulocity-python-api/issues/7):
13+
Improved caching and user experience when creating CumulocityApp instances. Added unit tests.
14+
15+
* Added possibility to resolve the tenant ID from authorization headers (both `Basic` and `Bearer`).
16+
17+
18+
## Version 1.0.1
19+
20+
### Changed
21+
22+
* The cumulocity-pyton-api library is now available on [PyPI](https://pypi.org) under the name `c8y_api` (see https://pypi.org/project/c8y-api/)
23+
* Updated README to reflect installation from PyPI
24+
25+
26+
## Version 1.0
27+
28+
Major refactoring of beta version:
29+
* Unified user experience
30+
* Complete documentation
31+
* Performance improvements
32+
* Introduced `CumulocityApp` to avoid mix-up with `CumulocityApi`
33+
* Complete unit tests
34+
* Structured integration tests
35+
* Removed samples (sorry, need to be re-organized)

c8y_api/_base_api.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
from __future__ import annotations
88

99
import json as json_lib
10-
from typing import Union, Dict, BinaryIO
10+
import os
11+
from typing import Union, Dict, BinaryIO, Set
1112

1213
import requests
1314
import collections
1415

1516

17+
def c8y_keys() -> Set[str]:
18+
"""Provide the names of defined Cumulocity environment variables.
19+
20+
Returns: A set of environment variable names, starting with 'C8Y_'
21+
"""
22+
return set(filter(lambda x: 'C8Y_' in x, os.environ.keys()))
23+
24+
1625
class CumulocityRestApi:
1726
"""Cumulocity base REST API.
1827

c8y_api/app/__init__.py

Lines changed: 124 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,15 @@
33
# and/or its subsidiaries and/or its affiliates and/or their licensors.
44
# Use, reproduction, transfer, publication or disclosure is prohibited except
55
# as specifically provided for in your License Agreement with Software AG.
6-
6+
import base64
77
import dataclasses
8+
import json
89
import logging
910
import os
10-
import requests
1111

12+
from functools import lru_cache
13+
14+
from c8y_api._base_api import c8y_keys
1215
from c8y_api._main_api import CumulocityApi
1316

1417

@@ -27,72 +30,94 @@ class CumulocityApp(CumulocityApi):
2730
2831
If the application is executed in PER_TENANT mode, all necessary
2932
authentication information is provided directly by Cumulocity as
30-
environment variables injected into the Docker container.
33+
environment variables injected into the Docker container. A corresponding
34+
instance can be created by invoking `CumulocityApp()` (without any parameter).
3135
3236
If the application is executed in MULTITENANT mode, only the so-called
3337
bootstrap user's authentication information is injected into the Docker
3438
container. An CumulocityApi instances providing access to a specific
3539
tenant can be obtained buy the application using the
36-
`get_tenant_instance` function.
40+
`get_tenant_instance` function or by invoking `CumulocityApp(id)` (with the
41+
ID of the tenant that should handle the requests).
3742
"""
3843
@dataclasses.dataclass
3944
class Auth:
4045
"""Bundles authentication information."""
4146
username: str
4247
password: str
4348

44-
__auth_by_tenant = {}
45-
__bootstrap_instance = None
46-
__tenant_instances = {}
49+
_auth_by_tenant = {}
50+
_bootstrap_instance = None
51+
_tenant_instances = {}
52+
53+
_log = logging.getLogger(__name__)
54+
55+
def __init__(self, tenant_id: str = None, application_key: str = None):
56+
"""Create a new tenant specific instance.
4757
48-
__log = logging.getLogger(__name__)
58+
Args:
59+
tenant_id (str|None): If None, it is assumed that the application
60+
is running in an PER_TENANT environment and the instance is
61+
created for the injected tenant information. Otherwise it is
62+
assumed that the application is running in a MULTITENANT
63+
environment and the provided ID reflects the ID of a
64+
subscribed tenant.
65+
application_key (str|None): An application key to include in
66+
all requests for tracking purposes.
4967
50-
def __init__(self, tenant_id=None, application_key=None):
51-
self.baseurl = self.__get_env('C8Y_BASEURL')
68+
Returns:
69+
A new CumulocityApp instance
70+
"""
5271
if tenant_id:
5372
self.tenant_id = tenant_id
54-
bootstrap_tenant_id = self.__get_env('C8Y_BOOTSTRAP_TENANT')
55-
bootstrap_username = self.__get_env('C8Y_BOOTSTRAP_USER')
56-
bootstrap_password = self.__get_env('C8Y_BOOTSTRAP_PASSWORD')
57-
self.__bootstrap_auth = self.__build_auth(bootstrap_tenant_id, bootstrap_username, bootstrap_password)
58-
auth = self.__get_auth(tenant_id)
59-
self.username = auth.username
60-
self.__password = auth.password
61-
super().__init__(self.baseurl, self.tenant_id, auth.username, auth.password,
73+
auth = self._get_tenant_auth(tenant_id)
74+
baseurl = self._get_env('C8Y_BASEURL')
75+
super().__init__(baseurl, tenant_id, auth.username, auth.password,
6276
tfa_token=None, application_key=application_key)
6377
else:
64-
self.tenant_id = self.__get_env('C8Y_TENANT')
65-
self.username = self.__get_env('C8Y_USER')
66-
self.__password = self.__get_env('C8Y_PASSWORD')
67-
super().__init__(self.baseurl, self.tenant_id, self.username, self.__password,
78+
baseurl = self._get_env('C8Y_BASEURL')
79+
tenant_id = self._get_env('C8Y_TENANT')
80+
username = self._get_env('C8Y_USER')
81+
password = self._get_env('C8Y_PASSWORD')
82+
super().__init__(baseurl, tenant_id, username, password,
6883
tfa_token=None, application_key=application_key)
6984

7085
@staticmethod
71-
def __get_env(name):
72-
val = os.getenv(name)
73-
assert val, "Missing environment variable: " + name
74-
return val
86+
def _get_env(name: str) -> str:
87+
try:
88+
return os.environ[name]
89+
except KeyError as e:
90+
raise ValueError(f"Missing environment variable: {name}. Found {', '.join(c8y_keys())}.") from e
7591

76-
@staticmethod
77-
def __build_auth(tenant_id, username, password):
78-
return f'{tenant_id}/{username}', password
79-
80-
def __get_auth(self, tenant_id):
81-
if tenant_id not in self.__auth_by_tenant:
82-
self.__update_auth_cache()
83-
return self.__auth_by_tenant[tenant_id]
84-
85-
def __update_auth_cache(self):
86-
r = requests.get(self.baseurl + '/application/currentApplication/subscriptions', auth=self.__bootstrap_auth)
87-
if r.status_code != 200:
88-
self.__log.error("Unable to perform GET request. Status: {}, Response: {}", r.status_code, r.text)
89-
self.__log.info("get subscriptions: {}", r.json())
90-
self.__auth_by_tenant.clear()
91-
for subscription in r.json()['users']:
92+
@classmethod
93+
def _get_tenant_auth(cls, tenant_id: str) -> Auth:
94+
if tenant_id not in cls._auth_by_tenant:
95+
cls._auth_by_tenant = cls._read_subscriptions()
96+
return cls._auth_by_tenant[tenant_id]
97+
98+
@classmethod
99+
def _read_subscriptions(cls):
100+
"""Read subscribed tenant's auth information.
101+
102+
Returns:
103+
A dict of tenant auth information by ID
104+
"""
105+
subscriptions = cls.get_bootstrap_instance().get('/application/currentApplication/subscriptions')
106+
cache = {}
107+
for subscription in subscriptions['users']:
92108
tenant = subscription['tenant']
93109
username = subscription['name']
94110
password = subscription['password']
95-
self.__auth_by_tenant[tenant] = CumulocityApp.Auth(username, password)
111+
cache[tenant] = CumulocityApp.Auth(username, password)
112+
return cache
113+
114+
@classmethod
115+
def _create_bootstrap_instance(cls) -> CumulocityApi:
116+
baseurl = cls._get_env('C8Y_BASEURL')
117+
tenant_id = cls._get_env('C8Y_BOOTSTRAP_TENANT')
118+
username = cls._get_env('C8Y_BOOTSTRAP_USER')
119+
password = cls._get_env('C8Y_BOOTSTRAP_PASSWORD')
120+
return CumulocityApi(baseurl, tenant_id, username, password)
96121

97122
@classmethod
98123
def get_bootstrap_instance(cls) -> CumulocityApi:
@@ -102,21 +127,70 @@ def get_bootstrap_instance(cls) -> CumulocityApi:
102127
Returns:
103128
A CumulocityApi instance authorized for the bootstrap user
104129
"""
105-
if not cls.__bootstrap_instance:
106-
cls.__bootstrap_instance = CumulocityApp()
107-
return cls.__bootstrap_instance
130+
if not cls._bootstrap_instance:
131+
cls._bootstrap_instance = cls._create_bootstrap_instance()
132+
return cls._bootstrap_instance
108133

109134
@classmethod
110-
def get_tenant_instance(cls, tenant_id) -> CumulocityApi:
135+
def get_tenant_instance(cls, tenant_id: str = None, headers: dict = None) -> CumulocityApi:
111136
"""Provide access to a tenant-specific instance in a multi-tenant
112137
application setup.
113138
114139
Args:
115140
tenant_id (str): ID of the tenant to get access to
141+
headers (dict): Inbound request headers, the tenant ID
142+
is resolved from the Authorization header
116143
117144
Returns:
118145
A CumulocityApi instance authorized for a tenant user
119146
"""
120-
if tenant_id not in cls.__tenant_instances:
121-
cls.__tenant_instances[tenant_id] = CumulocityApp(tenant_id)
122-
return cls.__tenant_instances[tenant_id]
147+
# (1) if the tenant ID is specified we just
148+
if tenant_id:
149+
return cls._get_tenant_instance(tenant_id)
150+
151+
# (2) otherwise, look for the Authorization header
152+
if not headers:
153+
raise RuntimeError("At least one of 'tenant_id' or 'headers' must be specified.")
154+
155+
auth_header = headers[next(filter(lambda k: 'Authorization'.upper() == k.upper(), headers.keys()))]
156+
if not auth_header:
157+
raise ValueError("Missing Authentication header. Unable to resolve tenant ID.")
158+
159+
return cls._get_tenant_instance(cls._resolve_tenant_id_from_auth_header(auth_header))
160+
161+
@classmethod
162+
def _get_tenant_instance(cls, tenant_id: str) -> CumulocityApi:
163+
if tenant_id not in cls._tenant_instances:
164+
cls._tenant_instances[tenant_id] = CumulocityApp(tenant_id)
165+
return cls._tenant_instances[tenant_id]
166+
167+
@classmethod
168+
@lru_cache(maxsize=128, typed=False)
169+
def _resolve_tenant_id_from_auth_header(cls, auth_header: str) -> str:
170+
auth_type, auth_value = auth_header.split(' ')
171+
172+
if auth_type.upper() == 'BASIC':
173+
return cls._resolve_tenant_id_basic(auth_value)
174+
if auth_type.upper() == 'BEARER':
175+
return cls._resolve_tenant_id_token(auth_value)
176+
177+
raise ValueError(f"Unexpected authorization header type: {auth_type}")
178+
179+
@staticmethod
180+
def _resolve_tenant_id_basic(auth_string: str) -> str:
181+
decoded = base64.b64decode(bytes(auth_string, 'utf-8'))
182+
username = decoded.split(b':', 1)[0]
183+
if b'/' not in username:
184+
raise ValueError(f"nable to resolve tenant ID. Username '{username}' does not appear to include it.")
185+
return username.split(b'/', 1)[0].decode('utf-8')
186+
187+
@classmethod
188+
def _resolve_tenant_id_token(cls, auth_token: str) -> str:
189+
# we assume that the token is an JWT token
190+
jwt_parts = auth_token.split('.')
191+
if len(jwt_parts) != 3:
192+
raise ValueError("Unexpected token format (not an JWT?). Unable to resolve tenant ID.")
193+
jwt_body = json.loads(base64.b64decode(jwt_parts[1].encode('utf-8')))
194+
if 'ten' not in jwt_body:
195+
raise ValueError("Unexpected token format (missing 'ten' claim). Unable to resolve tenant ID.")
196+
return jwt_body['ten']

integration_tests/conftest.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from dotenv import load_dotenv
1313
import pytest
1414

15+
from c8y_api._base_api import c8y_keys
1516
from c8y_api.app import CumulocityApp
1617
from c8y_api.model import Device
1718

@@ -49,9 +50,6 @@ def logger():
4950
def test_environment(logger):
5051
"""Prepare the environment, i.e. read a .env file if found."""
5152

52-
def c8y_keys():
53-
return filter(lambda x: 'C8Y_' in x, os.environ.keys())
54-
5553
# check if there is a .env file
5654
if os.path.exists('.env'):
5755
logger.info("Environment file (.env) exists and will be considered.")

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ responses~=0.13
77
python-dotenv~=0.19.0
88
setuptools_scm~=6.2
99
invoke~=1.6.0
10-
pylint=2.11.1
10+
pylint=2.11.1
11+
PyJWT~=2.3.0

0 commit comments

Comments
 (0)