Skip to content

Commit 2adb06d

Browse files
authored
Feature/improve applications handling (#31)
* Added build and sample utils. * Enforcing sample file name as parameter; Loading only .env, not .env-* files. * Moved env_util to samples. * Adding support for custom .env-* files. * Adding microservice handling utilities. * Completing applications implementation. * Multipart object is now optional for post_file function. * Using UTF-8 encoding to open env file. * Updated changelog.
1 parent 37ccb27 commit 2adb06d

File tree

7 files changed

+337
-22
lines changed

7 files changed

+337
-22
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Work in progress
44

5+
* Improved Applications API.
6+
7+
* Added microservice utilities for easier testing of provided samples.
8+
59
* Added Tenant Options API support.
610

711
## Version 1.4

c8y_api/_base_api.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def post(self, resource: str, json: dict, accept: str = None, content_type: str
192192
return r.json()
193193
return {}
194194

195-
def post_file(self, resource: str, file: str | BinaryIO, object: dict,
195+
def post_file(self, resource: str, file: str | BinaryIO, object: dict = None,
196196
accept: str = None, content_type: str = 'application/octet-stream'):
197197
"""Generic HTTP POST wrapper.
198198
@@ -217,10 +217,9 @@ def post_file(self, resource: str, file: str | BinaryIO, object: dict,
217217
"""
218218

219219
def perform_post(open_file):
220-
files = {
221-
'object': (None, json_lib.dumps(object)),
222-
'file': (None, open_file, content_type or 'application/octet-stream')
223-
}
220+
files = {'file': (None, open_file, content_type or 'application/octet-stream')}
221+
if object:
222+
files['object'] = (None, json_lib.dumps(object))
224223
additional_headers = self._prepare_headers(accept=accept)
225224
return self.session.post(self.base_url + resource, files=files, headers=additional_headers)
226225

c8y_api/model/applications.py

Lines changed: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from __future__ import annotations
88

9-
from typing import Generator, List
9+
from typing import Generator, List, BinaryIO
1010

1111
from c8y_api._base_api import CumulocityRestApi
1212
from c8y_api.model._base import SimpleObject, CumulocityResource
@@ -22,22 +22,93 @@ class Application(SimpleObject):
2222
See also: https://cumulocity.com/api/#tag/Application-API
2323
"""
2424
_parser = SimpleObjectParser({
25-
'name': 'name',
26-
'type': 'type',
27-
'availability': 'availability'})
28-
_resource = 'application/applications'
25+
'_u_name': 'name',
26+
'_u_type': 'type',
27+
'_u_key': 'key',
28+
'_u_availability': 'availability',
29+
'owner': 'owner',
30+
'manifest': 'manifest',
31+
'_u_roles': 'roles',
32+
'_u_required_roles': 'requiredRoles',
33+
'_u_breadcrumbs': 'breadcrumbs',
34+
'__u_content_security_policy': 'contentSecurityPolicy',
35+
'_u_dynamic_options_url': 'dynamicOptionsUrl',
36+
'__u_global_title': 'globalTitle',
37+
'_u_legacy': 'legacy',
38+
'_u_rightDrawer': 'rightDrawer',
39+
'_u_upgrade': 'upgrade'
40+
})
41+
_resource = '/application/applications'
42+
_accept = 'application/vnd.com.nsn.cumulocity.application+json'
43+
_not_updatable = ['owner']
2944

3045
EXTERNAL_TYPE = "EXTERNAL"
3146
HOSTED_TYPE = "HOSTED"
3247
MICROSERVICE_TYPE = "MICROSERVICE"
3348

34-
def __init__(self, c8y: CumulocityRestApi = None, name: str = None, type: str = None, availability: str = None,
35-
owner: str = None):
49+
PRIVATE_AVAILABILITY = 'PRIVATE'
50+
MARKET_AVAILABILITY = 'MARKET'
51+
52+
def __init__(self, c8y: CumulocityRestApi = None, name: str = None, key: str = None, type: str = None,
53+
availability: str = None, context_path: str = None, manifest: dict = None,
54+
roles: List[str] = None, required_roles: List[str] = None,
55+
breadcrumbs: bool = None, content_security_policy: str = None,
56+
dynamic_options_url: str = None, global_title: str = None,
57+
legacy: bool = None, right_drawer: bool = None, upgrade: bool = None):
58+
"""Create a new Application object.
59+
60+
Args:
61+
c8y (CumulocityRestApi): Cumulocity connection reference; needs
62+
to be set for direct manipulation (create, delete)
63+
name (str): Name of the application
64+
key (str): Key to identify the application
65+
type (str): Type of the application
66+
availability (str): Application access level for tenants
67+
context_path (str): The path where the application is accessible
68+
manifest (dict): Microservice or web application manifest
69+
roles (str): List of roles provided by the application
70+
required_roles (str): List of roles required by the application
71+
breadcrumbs (bool): Whether the (web) application uses breadcrumbs
72+
content_security_policy (str): The content security policy of the application
73+
dynamic_options_url (str): A URL to a JSON object with dynamic content options
74+
global_title (str): The global title of the application
75+
legacy (bool): Whether the (web) application is of legacy type
76+
right_drawer (bool): Whether the (web) application uses the
77+
right hand context menu
78+
upgrade (bool): Whether the (web) application uses both Angular and AngularJS
79+
"""
3680
super().__init__(c8y=c8y)
37-
self.name = name
38-
self.type = type
39-
self.availability = availability
40-
self.owner = owner
81+
self._u_name = name
82+
self._u_type = type
83+
self._u_key = key
84+
self.owner = None
85+
self._u_availability = availability
86+
self._u_contextPath = context_path
87+
self.manifest = manifest
88+
self._u_roles = roles
89+
self._u_required_roles = required_roles
90+
self._u_breadcrumbs = breadcrumbs
91+
self.__u_content_security_policy = content_security_policy
92+
self._u_dynamic_options_url = dynamic_options_url
93+
self.__u_global_title = global_title
94+
self._u_legacy = legacy
95+
self._u_rightDrawer = right_drawer
96+
self._u_upgrade = upgrade
97+
98+
name = SimpleObject.UpdatableProperty('_u_name')
99+
type = SimpleObject.UpdatableProperty('_u_type')
100+
key = SimpleObject.UpdatableProperty('_u_key')
101+
availability = SimpleObject.UpdatableProperty('_u_availability')
102+
context_path = SimpleObject.UpdatableProperty('_u_contextPath')
103+
roles = SimpleObject.UpdatableProperty('_u_roles')
104+
required_roles = SimpleObject.UpdatableProperty('_u_required_roles')
105+
breadcrumbs = SimpleObject.UpdatableProperty('_u_breadcrumbs')
106+
content_security_policy = SimpleObject.UpdatableProperty('__u_content_security_policy')
107+
dynamic_options_url = SimpleObject.UpdatableProperty('_u_dynamic_options_url')
108+
global_title = SimpleObject.UpdatableProperty('__u_global_title')
109+
legacy = SimpleObject.UpdatableProperty('_u_legacy')
110+
right_drawer = SimpleObject.UpdatableProperty('_u_rightDrawer')
111+
upgrade = SimpleObject.UpdatableProperty('_u_upgrade')
41112

42113
@classmethod
43114
def from_json(cls, json: dict) -> Application:
@@ -46,6 +117,30 @@ def from_json(cls, json: dict) -> Application:
46117
obj.owner = json['owner']['tenant']['id']
47118
return obj
48119

120+
def create(self) -> Application:
121+
"""Create the Application within the database.
122+
123+
Returns:
124+
A fresh Application object representing what was
125+
created within the database (including the ID).
126+
"""
127+
return super()._create()
128+
129+
def update(self) -> Application:
130+
"""Update the Application within the database.
131+
132+
Note: This will only send changed fields to increase performance.
133+
134+
Returns:
135+
A fresh Application object representing what the updated
136+
state within the database (including the ID).
137+
"""
138+
return super()._update()
139+
140+
def delete(self):
141+
"""Delete the Application within the database."""
142+
super()._delete()
143+
49144

50145
class Applications(CumulocityResource):
51146
"""Provides access to the Application API.
@@ -79,7 +174,7 @@ def select(self, name: str = None, type: str = None, owner: str = None, user: st
79174
fetched from the database as long there is a consumer for them.
80175
81176
All parameters are considered to be filters, limiting the result set
82-
to objects which meet the filters specification. Filters can be
177+
to objects which meet the filters' specification. Filters can be
83178
combined (within reason).
84179
85180
Args:
@@ -121,3 +216,14 @@ def get_all(self, name: str = None, type: str = None, owner: str = None, user: s
121216
return list(self.select(name=name, type=type, owner=owner, user=user,
122217
tenant=tenant, subscriber=subscriber, provided_for=provided_for,
123218
limit=limit, page_size=page_size))
219+
220+
def upload_attachment(self, application_id: str, file: str | BinaryIO):
221+
"""Upload application binary for a registered application.
222+
223+
Args:
224+
application_id (str): The Cumulocity object ID of the application
225+
file (str|BinaryIO): File path or file-like object to upload.
226+
227+
See also: https://cumulocity.com/api/#tag/Application-binaries
228+
"""
229+
self.c8y.post_file(self.build_object_path(application_id) + '/binaries', file=file)

samples/build.sh

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
# Copyright (c) 2020 Software AG,
2+
# Darmstadt, Germany and/or Software AG USA Inc., Reston, VA, USA,
3+
# and/or its subsidiaries and/or its affiliates and/or their licensors.
4+
# Use, reproduction, transfer, publication or disclosure is prohibited except
5+
# as specifically provided for in your License Agreement with Software AG.
16

27
NAME="$1"
38
VERSION="$2"
@@ -34,9 +39,17 @@ cp "./samples/$NAME.py" "$BUILD_DIR"
3439
cp -r "./c8y_api" "$BUILD_DIR"
3540
sed -e "s/{VERSION}/$VERSION/g" ./samples/cumulocity.json > "$BUILD_DIR/cumulocity.json"
3641
sed -e "s/{SAMPLE}/$NAME/g" ./samples/Dockerfile > "$BUILD_DIR/Dockerfile"
42+
# extend cumulocity.json is defined
43+
if [[ -r ./samples/cumulocity-$NAME.json ]]; then
44+
echo -n "Found custom extension at './samples/cumulocity-$NAME.json'. Applying ..."
45+
tmp=`tempfile`
46+
jq -s '.[0] + .[1]' "$BUILD_DIR/cumulocity.json" ./samples/cumulocity-$NAME.json > $tmp
47+
mv $tmp "$BUILD_DIR/cumulocity.json"
48+
echo " Done."
49+
fi
3750

3851
# build image
39-
52+
echo "Building image ..."
4053
docker build -t "$NAME" "$BUILD_DIR"
4154
docker save -o "$DIST_DIR/image.tar" "$NAME"
4255
zip -j "$DIST_DIR/$IMG_NAME.zip" "$BUILD_DIR/cumulocity.json" "$DIST_DIR/image.tar"

samples/util.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) 2020 Software AG,
2+
# Darmstadt, Germany and/or Software AG USA Inc., Reston, VA, USA,
3+
# and/or its subsidiaries and/or its affiliates and/or their licensors.
4+
# Use, reproduction, transfer, publication or disclosure is prohibited except
5+
# as specifically provided for in your License Agreement with Software AG.
6+
7+
from __future__ import annotations
8+
9+
import inspect
10+
import os
11+
12+
import dotenv
13+
14+
15+
def load_dotenv(sample_name: str | None = None):
16+
"""Load environment variables from .env files.
17+
18+
This function will look for two files within the working directory:
19+
A general `.env` file and a sample specific .env-{sample_name} file
20+
which has higher priority.
21+
"""
22+
# load general .env
23+
dotenv.load_dotenv()
24+
# check and load sample .env
25+
if not sample_name:
26+
caller_file = inspect.stack()[1].filename
27+
sample_name = os.path.splitext(os.path.split(caller_file)[1])[0]
28+
29+
sample_env = f'.env-{sample_name}'
30+
if os.path.exists(sample_env):
31+
print(f"Found custom .env extension: {sample_env}")
32+
with open(sample_env, 'r', encoding='UTF-8') as f:
33+
dotenv.load_dotenv(stream=f)

tasks.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from invoke import task
88
from setuptools_scm import get_version
99

10+
import util.microservice_util as ms_util
11+
1012

1113
@task
1214
def show_version(_):
@@ -43,13 +45,53 @@ def build(c):
4345
"version": "Microservice version. Defaults to '1.0.0'.",
4446
})
4547
def build_ms(c, sample, version='1.0.0'):
46-
"""Build a Cumulocity micro service.
48+
"""Build a Cumulocity microservice binary for upload.
4749
48-
This will build a ready to deploy Cumulocity micro service from a sample
50+
This will build a ready to deploy Cumulocity microservice from a sample
4951
file within the `samples` folder. Any sample Python script can be used
50-
(if it implements micro service logic).
52+
(if it implements microservice logic).
5153
5254
Use the file name without .py extension as name. The build microservice
5355
will use a similar name, following Cumulocity naming guidelines.
5456
"""
55-
c.run(f'samples/build.sh {sample} {version}')
57+
sample_name = ms_util.format_sample_name(sample)
58+
c.run(f'samples/build.sh {sample_name} {version}')
59+
60+
61+
@task(help={
62+
'sample': "Which sample to register."
63+
})
64+
def register_ms(c, sample):
65+
"""Register a sample as microservice at Cumulocity."""
66+
ms_util.register_microservice(ms_util.format_sample_name(sample))
67+
68+
69+
@task(help={
70+
'sample': "Which sample to unregister."
71+
})
72+
def unregister_ms(c, sample):
73+
"""Unregister a sample microservice from Cumulocity."""
74+
ms_util.unregister_microservice(ms_util.format_sample_name(sample))
75+
76+
77+
@task(help={
78+
'sample': "Which sample to register."
79+
})
80+
def get_credentials(c, sample):
81+
"""Unregister a sample microservice from Cumulocity."""
82+
user, password = ms_util.get_credentials(ms_util.format_sample_name(sample))
83+
print(f"Username: {user}\n"
84+
f"Password: {password}")
85+
86+
87+
@task(help={
88+
'sample': "Which sample to create a .env file for."
89+
})
90+
def create_env(c, sample):
91+
"""Create a sample specific .env-{sample_name} file using the
92+
credentials of a corresponding microservice registered at Cumulocity."""
93+
sample_name = ms_util.format_sample_name(sample)
94+
user, password = ms_util.get_credentials(sample_name)
95+
with open(f'.env-{sample_name}', 'w') as f:
96+
f.write(f"C8Y_USER={user}\n"
97+
f"C8Y_PASSWORD={password}\n")

0 commit comments

Comments
 (0)