contentgrid-hal-client 0.0.1__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
+ Copyright 2024 Xenit Solutions
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, software
10
+ distributed under the License is distributed on an "AS IS" BASIS,
11
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ See the License for the specific language governing permissions and
13
+ limitations under the License.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.1
2
+ Name: contentgrid-hal-client
3
+ Version: 0.0.1
4
+ Summary: Python Client for interacting with HAL-forms application
5
+ Author-email: Ranec Belpaire <ranec.belpaire@xenit.eu>
6
+ License: Copyright 2024 Xenit Solutions
7
+
8
+ Licensed under the Apache License, Version 2.0 (the "License");
9
+ you may not use this file except in compliance with the License.
10
+ You may obtain a copy of the License at
11
+
12
+ http://www.apache.org/licenses/LICENSE-2.0
13
+
14
+ Unless required by applicable law or agreed to in writing, software
15
+ distributed under the License is distributed on an "AS IS" BASIS,
16
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ See the License for the specific language governing permissions and
18
+ limitations under the License.
19
+ Classifier: Development Status :: 3 - Alpha
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Requires-Python: >=3.5
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: requests==2.31.0
26
+ Requires-Dist: uri-template==1.3.0
27
+
28
+ # HALFormsClient Library
29
+
30
+ ## Overview
31
+
32
+ The `HALFormsClient` library provides a Python client for interacting with RESTful services that use the HAL (Hypertext Application Language) and HAL-Forms standards. This library simplifies the handling of HAL responses and their embedded resources, enabling easy navigation and interaction with APIs that follow the HAL and HAL-Forms specifications.
33
+
34
+ ## Features
35
+
36
+ - Automatic token management for service accounts.
37
+ - Robust retry mechanisms for HTTP requests.
38
+ - Easy navigation of HAL links and embedded resources.
39
+ - Support for CURIEs (Compact URIs) for compacting and expanding link relations.
40
+ - Handling of common HTTP methods (`GET`, `POST`, `PATCH`, `DELETE`, `PUT`) with ease.
41
+ - Validation and transformation of HAL links and URIs.
42
+ - Customizable client configuration, including endpoint and authentication details.
43
+
44
+ ## Installation
45
+
46
+ To install the library, use pip:
47
+
48
+ ```bash
49
+ pip install contentgrid-hal-client
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Initialization
55
+
56
+ To initialize the `HALFormsClient`, you need to provide the endpoint of the HAL service and optionally authentication details:
57
+
58
+ ```python
59
+ from halformsclient import HALFormsClient
60
+
61
+ # Initialize using service account
62
+ client = HALFormsClient(
63
+ client_endpoint="https://api.example.com",
64
+ auth_uri="https://auth.example.com",
65
+ client_id="your_client_id",
66
+ client_secret="your_client_secret",
67
+ )
68
+
69
+ # Initialize using bearer token
70
+ client = HALFormsClient(
71
+ client_endpoint="https://api.example.com",
72
+ auth_uri="https://auth.example.com",
73
+ token="your_token"
74
+ )
75
+
76
+
77
+ # Initialize using session cookie
78
+ client = HALFormsClient(
79
+ client_endpoint="https://api.example.com",
80
+ auth_uri="https://auth.example.com",
81
+ session_cookie="your_session_cookie"
82
+ )
83
+
84
+ ```
85
+
86
+ ### Fetching and Interacting with HAL Resources
87
+
88
+ You can fetch a HAL resource and interact with it using the provided methods:
89
+
90
+ ```python
91
+ # Fetch a resource by following a link
92
+ resource = client.follow_link(HALLink(uri="/some/resource"))
93
+
94
+ # Access metadata
95
+ print(resource.metadata)
96
+
97
+ # Access links
98
+ links = resource.get_links("key")
99
+
100
+ # Access embedded resources
101
+ embedded_objects = resource.get_embedded_objects_by_key("embedded_key")
102
+
103
+ # Update a resource with PUT or PATCH
104
+ resource.put_data({"key": "value"})
105
+ resource.patch_data({"key": "new_value"})
106
+
107
+ # Delete a resource
108
+ resource.delete()
109
+ ```
110
+
111
+ ### Handling CURIEs
112
+
113
+ CURIEs (Compact URIs) are supported for compacting and expanding link relations:
114
+
115
+ ```python
116
+ curie_registry = CurieRegistry(curies=[{"name": "ex", "href": "https://example.com/{rel}"}])
117
+ expanded_uri = curie_registry.expand_curie("ex:some_relation")
118
+ compact_uri = curie_registry.compact_curie("https://example.com/some_relation")
119
+ ```
120
+
121
+ ### Error Handling
122
+
123
+ The library provides custom exceptions for common HTTP errors:
124
+
125
+ ```python
126
+ from halformsclient.exceptions import BadRequest, Unauthorized
127
+
128
+ try:
129
+ resource = client.follow_link(HALLink(uri="/some/invalid/resource"))
130
+ except BadRequest as e:
131
+ print(f"Bad request: {str(e)}")
132
+ except Unauthorized as e:
133
+ print(f"Unauthorized: {str(e)}")
134
+ ```
@@ -0,0 +1,107 @@
1
+ # HALFormsClient Library
2
+
3
+ ## Overview
4
+
5
+ The `HALFormsClient` library provides a Python client for interacting with RESTful services that use the HAL (Hypertext Application Language) and HAL-Forms standards. This library simplifies the handling of HAL responses and their embedded resources, enabling easy navigation and interaction with APIs that follow the HAL and HAL-Forms specifications.
6
+
7
+ ## Features
8
+
9
+ - Automatic token management for service accounts.
10
+ - Robust retry mechanisms for HTTP requests.
11
+ - Easy navigation of HAL links and embedded resources.
12
+ - Support for CURIEs (Compact URIs) for compacting and expanding link relations.
13
+ - Handling of common HTTP methods (`GET`, `POST`, `PATCH`, `DELETE`, `PUT`) with ease.
14
+ - Validation and transformation of HAL links and URIs.
15
+ - Customizable client configuration, including endpoint and authentication details.
16
+
17
+ ## Installation
18
+
19
+ To install the library, use pip:
20
+
21
+ ```bash
22
+ pip install contentgrid-hal-client
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### Initialization
28
+
29
+ To initialize the `HALFormsClient`, you need to provide the endpoint of the HAL service and optionally authentication details:
30
+
31
+ ```python
32
+ from halformsclient import HALFormsClient
33
+
34
+ # Initialize using service account
35
+ client = HALFormsClient(
36
+ client_endpoint="https://api.example.com",
37
+ auth_uri="https://auth.example.com",
38
+ client_id="your_client_id",
39
+ client_secret="your_client_secret",
40
+ )
41
+
42
+ # Initialize using bearer token
43
+ client = HALFormsClient(
44
+ client_endpoint="https://api.example.com",
45
+ auth_uri="https://auth.example.com",
46
+ token="your_token"
47
+ )
48
+
49
+
50
+ # Initialize using session cookie
51
+ client = HALFormsClient(
52
+ client_endpoint="https://api.example.com",
53
+ auth_uri="https://auth.example.com",
54
+ session_cookie="your_session_cookie"
55
+ )
56
+
57
+ ```
58
+
59
+ ### Fetching and Interacting with HAL Resources
60
+
61
+ You can fetch a HAL resource and interact with it using the provided methods:
62
+
63
+ ```python
64
+ # Fetch a resource by following a link
65
+ resource = client.follow_link(HALLink(uri="/some/resource"))
66
+
67
+ # Access metadata
68
+ print(resource.metadata)
69
+
70
+ # Access links
71
+ links = resource.get_links("key")
72
+
73
+ # Access embedded resources
74
+ embedded_objects = resource.get_embedded_objects_by_key("embedded_key")
75
+
76
+ # Update a resource with PUT or PATCH
77
+ resource.put_data({"key": "value"})
78
+ resource.patch_data({"key": "new_value"})
79
+
80
+ # Delete a resource
81
+ resource.delete()
82
+ ```
83
+
84
+ ### Handling CURIEs
85
+
86
+ CURIEs (Compact URIs) are supported for compacting and expanding link relations:
87
+
88
+ ```python
89
+ curie_registry = CurieRegistry(curies=[{"name": "ex", "href": "https://example.com/{rel}"}])
90
+ expanded_uri = curie_registry.expand_curie("ex:some_relation")
91
+ compact_uri = curie_registry.compact_curie("https://example.com/some_relation")
92
+ ```
93
+
94
+ ### Error Handling
95
+
96
+ The library provides custom exceptions for common HTTP errors:
97
+
98
+ ```python
99
+ from halformsclient.exceptions import BadRequest, Unauthorized
100
+
101
+ try:
102
+ resource = client.follow_link(HALLink(uri="/some/invalid/resource"))
103
+ except BadRequest as e:
104
+ print(f"Bad request: {str(e)}")
105
+ except Unauthorized as e:
106
+ print(f"Unauthorized: {str(e)}")
107
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel", "setuptools_scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "contentgrid-hal-client"
7
+ dynamic = ["version"]
8
+ description = "Python Client for interacting with HAL-forms application"
9
+ readme = "README.md"
10
+ authors = [{ name = "Ranec Belpaire", email = "ranec.belpaire@xenit.eu" }]
11
+ license = { file = "LICENSE" }
12
+
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ ]
18
+
19
+ dependencies = [
20
+ "requests==2.31.0",
21
+ "uri-template==1.3.0",
22
+ ]
23
+
24
+ requires-python = ">=3.5"
25
+
26
+ [tool.setuptools_scm]
27
+ write_to = "_version.txt"
28
+ root = "../"
@@ -0,0 +1,2 @@
1
+ Requests==2.31.0
2
+ uri-template==1.3.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ import setuptools
2
+ if __name__ == "__main__":
3
+ setuptools.setup()
@@ -0,0 +1 @@
1
+ from .hal import HALLink, CurieRegistry, Curie, HALResponse, InteractiveHALResponse, HALFormsClient
@@ -0,0 +1,25 @@
1
+ from requests.exceptions import HTTPError
2
+
3
+ class NotFound(HTTPError):
4
+ def __init__(self, *args: object) -> None:
5
+ super().__init__(*args)
6
+
7
+ class Unauthorized(HTTPError):
8
+ def __init__(self, *args: object) -> None:
9
+ super().__init__(*args)
10
+
11
+ class BadRequest(HTTPError):
12
+ def __init__(self, *args: object) -> None:
13
+ super().__init__(*args)
14
+
15
+ class IncorrectAttributeType(Exception):
16
+ def __init__(self, *args: object) -> None:
17
+ super().__init__(*args)
18
+
19
+ class NonExistantAttribute(Exception):
20
+ def __init__(self, *args: object) -> None:
21
+ super().__init__(*args)
22
+
23
+ class MissingRequiredAttribute(Exception):
24
+ def __init__(self, *args: object) -> None:
25
+ super().__init__(*args)
@@ -0,0 +1,362 @@
1
+ from http import HTTPStatus
2
+ from json import JSONDecodeError
3
+ import json
4
+ import logging
5
+ import re
6
+ from typing import List, Optional
7
+ from urllib.parse import urljoin
8
+ import uri_template
9
+ import requests
10
+ from requests.adapters import Retry, HTTPAdapter
11
+ from .exceptions import BadRequest, Unauthorized
12
+ from .token_utils import get_application_token
13
+
14
+ class HALLink:
15
+ def __init__(self, uri: str, name: Optional[str] = None, title: Optional[str] = None, link_relation: Optional[str] = None) -> None:
16
+ self.name: Optional[str] = name
17
+ self.title: Optional[str] = title
18
+ self.uri: str = uri
19
+ self.link_relation = link_relation
20
+
21
+ class CurieRegistry:
22
+ def __init__(self, curies: List[dict]) -> None:
23
+ self.curies = {
24
+ curie["name"]: Curie(curie["name"], curie["href"]) for curie in curies
25
+ }
26
+
27
+ def expand_curie(self, rel):
28
+ if ":" in rel:
29
+ prefix, suffix = rel.split(":", 1)
30
+ if prefix in self.curies.keys():
31
+ return uri_template.URITemplate(self.curies[prefix].url_template).expand(
32
+ rel=suffix
33
+ )
34
+ return rel
35
+
36
+ def compact_curie(self, link: str):
37
+ for curie in self.curies.values():
38
+ variable_names = re.findall(r'{(.*?)}', curie.url_template)
39
+ pattern = re.sub(r'{(.*?)}', r'(?P<\g<1>>.+)', curie.url_template)
40
+ match = re.match(pattern, link)
41
+ if match:
42
+ extracted_values = match.groupdict()
43
+ variable_map = {variable_names[i]: extracted_values[variable_names[i]] for i in range(len(variable_names))}
44
+ if "rel" in variable_map.keys():
45
+ return f"{curie.prefix}:{variable_map['rel']}"
46
+ return link
47
+
48
+ class Curie:
49
+ def __init__(self, prefix: str, url_template: str) -> None:
50
+ assert uri_template.validate(template=url_template)
51
+ self.url_template = url_template
52
+ self.url_prefix = str(uri_template.URITemplate(url_template).expand(rel=''))
53
+ self.prefix = prefix
54
+
55
+ class HALResponse:
56
+ def __init__(self, data: dict, curie_registry: CurieRegistry = None) -> None:
57
+ self.data: dict = data
58
+ self.links: dict = data.get("_links", None)
59
+ if self.links == None:
60
+ logging.warning(f"HALresponse [type {type(self)}] did not have a _links field.")
61
+ if self.links and "curies" in self.links.keys():
62
+ self.curie_registry: CurieRegistry = CurieRegistry(self.links["curies"])
63
+ elif curie_registry != None:
64
+ self.curie_registry: CurieRegistry = curie_registry
65
+ else:
66
+ self.curie_registry = CurieRegistry(curies=[])
67
+
68
+ self.embedded: dict = data.get("_embedded", None)
69
+ self.templates: dict = data.get("_templates", None)
70
+ self.metadata = {
71
+ key: value
72
+ for key, value in data.items()
73
+ if not key.startswith("_")
74
+ }
75
+
76
+ def has_link(self, linkrel : str) -> bool:
77
+ return len(self.get_links(linkrel=linkrel)) > 0
78
+
79
+ def get_link(self, linkrel: str) -> HALLink:
80
+ if self.has_link(linkrel=linkrel):
81
+ if linkrel in self.links.keys():
82
+ value = self.links[linkrel]
83
+ if type(value) == list:
84
+ raise Exception(f"Linkrel {linkrel} was multivalued. Use get_links instead.")
85
+ return self.get_links(linkrel=linkrel)[0]
86
+ else:
87
+ return None
88
+
89
+ def get_links(self, linkrel: str) -> List[HALLink]:
90
+ full_linkrel = linkrel
91
+ # compact linkrel if curie registry is available.
92
+ if self.curie_registry:
93
+ linkrel = self.curie_registry.compact_curie(linkrel)
94
+
95
+ if linkrel in self.links.keys():
96
+ value = self.links[linkrel]
97
+ if type(value) == list:
98
+ return [
99
+ HALLink(
100
+ name=v.get("name", None),
101
+ title=v.get("title", None),
102
+ uri=v["href"],
103
+ link_relation=full_linkrel
104
+ )
105
+ for v in value
106
+ ]
107
+ elif type(value) == dict:
108
+ return [
109
+ HALLink(
110
+ name=value.get("name", None),
111
+ title=value.get("title", None),
112
+ uri=value["href"],
113
+ link_relation=full_linkrel
114
+ )
115
+ ]
116
+
117
+ else:
118
+ raise Exception(f"Unkown HALLINK type {type(value)}")
119
+ else:
120
+ return []
121
+
122
+ def get_embedded_objects_by_key(self, key : str, infer_type:type = None) -> Optional[List["HALResponse"]]:
123
+ if self.embedded:
124
+ if not infer_type:
125
+ infer_type = HALResponse
126
+ return [
127
+ infer_type(data=v, curie_registry=self.curie_registry)
128
+ for v in self.embedded[self.curie_registry.compact_curie(key)]
129
+ ]
130
+ else:
131
+ return []
132
+
133
+ def get_self_link(self) -> HALLink:
134
+ return self.get_link("self")
135
+
136
+ def __str__(self) -> str:
137
+ return json.dumps(self.data, indent=4)
138
+
139
+ class InteractiveHALResponse(HALResponse):
140
+ def __init__(self, data: dict, client: "HALFormsClient", curie_registry: CurieRegistry = None) -> None:
141
+ super().__init__(data, curie_registry)
142
+ self.client : "HALFormsClient" = client
143
+
144
+ def get_embedded_objects_by_key(self, key, infer_type:type = None) -> Optional[List[HALResponse]]:
145
+ if self.embedded:
146
+ if not infer_type:
147
+ infer_type = InteractiveHALResponse
148
+
149
+ if issubclass(infer_type, InteractiveHALResponse):
150
+ return [
151
+ infer_type(data=v, client=self.client, curie_registry=self.curie_registry)
152
+ for v in self.embedded[self.curie_registry.compact_curie(key)]
153
+ ]
154
+ else:
155
+ return [
156
+ infer_type(data=v, curie_registry=self.curie_registry)
157
+ for v in self.embedded[self.curie_registry.compact_curie(key)]
158
+ ]
159
+ else:
160
+ return []
161
+
162
+ # Common operations
163
+ def refetch(self):
164
+ response = self.client.follow_link(self.get_self_link())
165
+ self.__init__(data=response.data, client=self.client, curie_registry=self.curie_registry)
166
+
167
+ def delete(self):
168
+ response = self.client.delete(self.get_self_link().uri)
169
+ self.client._validate_non_json_response(response)
170
+
171
+ def put_data(self, data : dict) -> None:
172
+ response = self.client.put(self.get_self_link().uri, json=data, headers={"Content-Type" : "application/json"})
173
+ data = self.client._validate_json_response(response)
174
+ # Reintialize class based on response data
175
+ self.__init__(data=data, client=self.client, curie_registry=self.curie_registry)
176
+
177
+ def patch_data(self, data : dict) -> None:
178
+ response = self.client.patch(self.get_self_link().uri, json=data, headers={"Content-Type" : "application/json"})
179
+ data = self.client._validate_json_response(response)
180
+ # Reintialize class based on response data
181
+ self.__init__(data=data, client=self.client, curie_registry=self.curie_registry)
182
+
183
+
184
+ class HALFormsClient(requests.Session):
185
+ def __init__(self,
186
+ client_endpoint: str,
187
+ auth_uri: str = None,
188
+ client_id: str = None,
189
+ client_secret: str = None,
190
+ token: str = None,
191
+ session_cookie : str = None,
192
+ pool_maxsize : int = 10,
193
+ ) -> None:
194
+ super().__init__()
195
+ self.token = token
196
+ self.session_cookie = session_cookie.replace("SESSION=","") if session_cookie else None
197
+
198
+ self.client_endpoint = client_endpoint
199
+ self.auth_uri = auth_uri
200
+
201
+ # Retries requests for status_forcelist response codes with backoff factor increasing wait time between each request
202
+ retries = Retry(total=5,
203
+ backoff_factor=0.2,
204
+ status_forcelist=[ 500, 502, 503, 504 ])
205
+
206
+ self.mount('http://', HTTPAdapter(max_retries=retries, pool_maxsize=pool_maxsize))
207
+
208
+ # Service account
209
+ self.client_id = client_id
210
+ self.client_secret = client_secret
211
+
212
+ self.has_service_account = self.client_id and self.client_secret
213
+
214
+ logging.info(f"ContentGrid deployment endpoint: {self.client_endpoint}")
215
+ logging.info(f"ContentGrid Auth URI: {self.auth_uri}")
216
+ if self.has_service_account:
217
+ logging.info(f"ContentGrid service account used...")
218
+ logging.info(
219
+ f"\t - Client id: {self.client_id}, Client secret: {self.client_secret[:4]}****..."
220
+ )
221
+ if not self.auth_uri:
222
+ raise Exception("Auth URI is required when using a service account")
223
+ logging.info("Fetching session token...")
224
+ self.headers["Authorization"] = (
225
+ f"Bearer {get_application_token(auth_uri=self.auth_uri, client_id=self.client_id, client_secret=self.client_secret)}"
226
+ )
227
+ elif self.token:
228
+ self.headers["Authorization"] = f"Bearer {self.token}"
229
+ elif self.session_cookie:
230
+ self.cookies.set(name="SESSION", value=self.session_cookie)
231
+ else:
232
+ raise Exception("Token or Service account (client_id & client_secret) is required.")
233
+
234
+ self.headers["Accept"] = "application/prs.hal-forms+json"
235
+ logging.info(f"Client Cookies : {self.cookies.items()}")
236
+
237
+ def _transform_hal_links_to_uris(self, attributes : dict) -> None:
238
+ for attribute, value in attributes.items():
239
+ if isinstance(value, list):
240
+ for i, v in enumerate(value):
241
+ if isinstance(v, HALLink):
242
+ attributes[attribute][i] = v.uri
243
+ else:
244
+ if isinstance(value, HALLink):
245
+ attributes[attribute] = value.uri
246
+
247
+ def _create_text_uri_list_payload(self, links : List[str | HALLink | HALResponse]) -> str:
248
+ uri_list = []
249
+ for link in links:
250
+ if isinstance(link, HALLink):
251
+ uri_list.append(link.uri)
252
+ elif isinstance(link, HALResponse):
253
+ uri_list.append(link.get_self_link().uri)
254
+ elif isinstance(link, str):
255
+ uri_list.append(link)
256
+ else:
257
+ raise BadRequest(f"Incorrect Link type {type(link)} in uri list payload, allowed types: HALLink, HALResponse or str")
258
+ return "\n".join(uri_list)
259
+
260
+ def _validate_non_json_response(self, response: requests.Response) -> requests.Response:
261
+ self._raise_for_status(response)
262
+ return response
263
+
264
+ def _validate_json_response(self, response: requests.Response) -> dict | str:
265
+ self._raise_for_status(response)
266
+ if response.status_code < 300:
267
+ try:
268
+ return response.json()
269
+ except JSONDecodeError as e:
270
+ logging.error(f"Failed to parse JSON, error: {str(e)}")
271
+ return response.content
272
+
273
+ def _raise_for_status(self, response: requests.Response):
274
+ """Raises :class:`HTTPError`, if one occurred."""
275
+
276
+ http_error_msg = ""
277
+ if hasattr(response, "reason") and isinstance(response.reason, bytes):
278
+ # We attempt to decode utf-8 first because some servers
279
+ # choose to localize their reason strings. If the string
280
+ # isn't utf-8, we fall back to iso-8859-1 for all other
281
+ # encodings. (See PR #3538)
282
+ try:
283
+ reason = response.reason.decode("utf-8")
284
+ except UnicodeDecodeError:
285
+ reason = response.reason.decode("iso-8859-1")
286
+ else:
287
+ if hasattr(response, "reason"):
288
+ reason = response.reason
289
+ else:
290
+ reason = "reason unknown"
291
+
292
+ if 400 <= response.status_code < 500:
293
+ http_error_msg = (
294
+ f"{response.status_code} Client Error: {reason} for url: {response.url}. response: {response.text}"
295
+ )
296
+
297
+ elif 500 <= response.status_code < 600:
298
+ http_error_msg = (
299
+ f"{response.status_code} Server Error: {reason} for url: {response.url}. response: {response.text}"
300
+ )
301
+
302
+ if http_error_msg:
303
+ raise requests.HTTPError(http_error_msg, response=self)
304
+
305
+
306
+ def _add_page_and_size_to_params(self, page, size , params):
307
+ # Check if params does not contain page or size, if not set it to page and size (or defaults)
308
+ # params dict has precendence over page and size variables
309
+ if not "page" in params.keys():
310
+ params["page"] = page
311
+ if not "size" in params.keys():
312
+ params["size"] = size
313
+ return params
314
+
315
+ def request(self, method, url, *args, **kwargs) -> requests.Response:
316
+ logging.debug(f"{method} - {urljoin(self.client_endpoint, url)}")
317
+ if "params" in kwargs:
318
+ logging.debug(f"params: {kwargs['params']}")
319
+ if "json" in kwargs:
320
+ logging.debug(f"Json payload: {json.dumps(kwargs['json'], indent=4)}")
321
+ response = super().request(
322
+ method, urljoin(self.client_endpoint, url), *args, **kwargs
323
+ )
324
+ if response.status_code == 401:
325
+ if (
326
+ "WWW-Authenticate" in response.headers.keys()
327
+ and "invalid_token" in response.headers["WWW-Authenticate"]
328
+ ):
329
+ logging.warning("ContentGrid authorization token expired.")
330
+ if self.has_service_account:
331
+ logging.debug("Refreshing token...")
332
+ self.headers["Authorization"] = (
333
+ f"Bearer {get_application_token(auth_uri=self.auth_uri, client_id=self.client_id, client_secret=self.client_secret)}"
334
+ )
335
+ response = super().request(
336
+ method, urljoin(self.client_endpoint, url), *args, **kwargs
337
+ )
338
+ else:
339
+ raise Unauthorized("Token invalid!")
340
+ return response
341
+
342
+ def follow_link(self, link: "HALLink", expect_json: bool = True, infer_type: type=HALResponse, params={}) -> HALResponse | str:
343
+ response = self.get(link.uri, params=params)
344
+ if expect_json:
345
+ if issubclass(infer_type, InteractiveHALResponse):
346
+ return infer_type(self._validate_json_response(response=response), client=self)
347
+ return infer_type(self._validate_json_response(response=response))
348
+ else:
349
+ self._validate_non_json_response(response=response)
350
+ return response.content
351
+
352
+ def get_method_from_string(self, http_method:str):
353
+ method_dict = {
354
+ "GET" : self.get,
355
+ "POST" : self.post,
356
+ "PATCH" : self.patch,
357
+ "DELETE" : self.delete,
358
+ "PUT" : self.put
359
+ }
360
+ if http_method not in method_dict.keys():
361
+ raise Exception(f"Unkown method from HAL-forms: {http_method}")
362
+ return method_dict[http_method]
@@ -0,0 +1,81 @@
1
+ '''
2
+ Utilities for requesting and handling access tokens.
3
+ '''
4
+ import requests as re
5
+
6
+ class TokenData():
7
+ '''
8
+ Token data for requesting an access token.
9
+
10
+ - scope: The scope of the token.
11
+ - grant_type: The grant type of the token.
12
+ - client_id: The client ID of the application.
13
+ - client_secret: The client secret of the application.
14
+ '''
15
+ def __init__(self, scope: str, grant_type: str, client_id: str, client_secret: str) -> None:
16
+ '''
17
+ Initialize a TokenData with the following parameters:
18
+
19
+ - scope: The scope of the token.
20
+ - grant_type: The grant type of the token.
21
+ - client_id: The client ID of the application.
22
+ - client_secret: The client secret of the application.
23
+ '''
24
+ self.scope = scope
25
+ self.grant_type = grant_type
26
+ self.client_id = client_id
27
+ self.client_secret = client_secret
28
+
29
+ def to_dict(self) -> dict:
30
+ '''
31
+ Get the token data as a dictionary.
32
+
33
+ Returns the token data as a dictionary.
34
+ '''
35
+ return {
36
+ "scope": self.scope,
37
+ "grant_type": self.grant_type,
38
+ "client_id": self.client_id,
39
+ "client_secret": self.client_secret
40
+ }
41
+
42
+
43
+ def request_token(uri: str, data: TokenData) -> str:
44
+ '''
45
+ Given a URI and token data, request an access token.
46
+
47
+ - uri: The URI to request the access token from.
48
+ - data: The token data to use for the request.
49
+
50
+ Returns the access token.
51
+ '''
52
+ headers = {
53
+ 'Content-Type': 'application/x-www-form-urlencoded'
54
+ }
55
+ data = data.to_dict()
56
+ res = re.post(uri, headers=headers, data=data)
57
+
58
+ if res.status_code != 200:
59
+ raise Exception(f'Failed to request token. Status code: {res.status_code}. Reason: {res.reason}')
60
+ else:
61
+ return res.json()['access_token']
62
+
63
+
64
+ def get_application_token(auth_uri: str, client_id: str, client_secret: str) -> str:
65
+ '''
66
+ Get an application token for the given client ID and client secret.
67
+
68
+ - auth_uri: The authorization URI of the application.
69
+ - client_id: The client ID of the application.
70
+ - client_secret: The client secret of the application.
71
+
72
+ Returns the application token.
73
+ '''
74
+ token_data = TokenData(
75
+ scope='email',
76
+ grant_type='client_credentials',
77
+ client_id=client_id,
78
+ client_secret=client_secret
79
+ )
80
+ acces_token = request_token(auth_uri, token_data)
81
+ return acces_token
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.1
2
+ Name: contentgrid-hal-client
3
+ Version: 0.0.1
4
+ Summary: Python Client for interacting with HAL-forms application
5
+ Author-email: Ranec Belpaire <ranec.belpaire@xenit.eu>
6
+ License: Copyright 2024 Xenit Solutions
7
+
8
+ Licensed under the Apache License, Version 2.0 (the "License");
9
+ you may not use this file except in compliance with the License.
10
+ You may obtain a copy of the License at
11
+
12
+ http://www.apache.org/licenses/LICENSE-2.0
13
+
14
+ Unless required by applicable law or agreed to in writing, software
15
+ distributed under the License is distributed on an "AS IS" BASIS,
16
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17
+ See the License for the specific language governing permissions and
18
+ limitations under the License.
19
+ Classifier: Development Status :: 3 - Alpha
20
+ Classifier: Operating System :: OS Independent
21
+ Classifier: Programming Language :: Python :: 3
22
+ Requires-Python: >=3.5
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: requests==2.31.0
26
+ Requires-Dist: uri-template==1.3.0
27
+
28
+ # HALFormsClient Library
29
+
30
+ ## Overview
31
+
32
+ The `HALFormsClient` library provides a Python client for interacting with RESTful services that use the HAL (Hypertext Application Language) and HAL-Forms standards. This library simplifies the handling of HAL responses and their embedded resources, enabling easy navigation and interaction with APIs that follow the HAL and HAL-Forms specifications.
33
+
34
+ ## Features
35
+
36
+ - Automatic token management for service accounts.
37
+ - Robust retry mechanisms for HTTP requests.
38
+ - Easy navigation of HAL links and embedded resources.
39
+ - Support for CURIEs (Compact URIs) for compacting and expanding link relations.
40
+ - Handling of common HTTP methods (`GET`, `POST`, `PATCH`, `DELETE`, `PUT`) with ease.
41
+ - Validation and transformation of HAL links and URIs.
42
+ - Customizable client configuration, including endpoint and authentication details.
43
+
44
+ ## Installation
45
+
46
+ To install the library, use pip:
47
+
48
+ ```bash
49
+ pip install contentgrid-hal-client
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ### Initialization
55
+
56
+ To initialize the `HALFormsClient`, you need to provide the endpoint of the HAL service and optionally authentication details:
57
+
58
+ ```python
59
+ from halformsclient import HALFormsClient
60
+
61
+ # Initialize using service account
62
+ client = HALFormsClient(
63
+ client_endpoint="https://api.example.com",
64
+ auth_uri="https://auth.example.com",
65
+ client_id="your_client_id",
66
+ client_secret="your_client_secret",
67
+ )
68
+
69
+ # Initialize using bearer token
70
+ client = HALFormsClient(
71
+ client_endpoint="https://api.example.com",
72
+ auth_uri="https://auth.example.com",
73
+ token="your_token"
74
+ )
75
+
76
+
77
+ # Initialize using session cookie
78
+ client = HALFormsClient(
79
+ client_endpoint="https://api.example.com",
80
+ auth_uri="https://auth.example.com",
81
+ session_cookie="your_session_cookie"
82
+ )
83
+
84
+ ```
85
+
86
+ ### Fetching and Interacting with HAL Resources
87
+
88
+ You can fetch a HAL resource and interact with it using the provided methods:
89
+
90
+ ```python
91
+ # Fetch a resource by following a link
92
+ resource = client.follow_link(HALLink(uri="/some/resource"))
93
+
94
+ # Access metadata
95
+ print(resource.metadata)
96
+
97
+ # Access links
98
+ links = resource.get_links("key")
99
+
100
+ # Access embedded resources
101
+ embedded_objects = resource.get_embedded_objects_by_key("embedded_key")
102
+
103
+ # Update a resource with PUT or PATCH
104
+ resource.put_data({"key": "value"})
105
+ resource.patch_data({"key": "new_value"})
106
+
107
+ # Delete a resource
108
+ resource.delete()
109
+ ```
110
+
111
+ ### Handling CURIEs
112
+
113
+ CURIEs (Compact URIs) are supported for compacting and expanding link relations:
114
+
115
+ ```python
116
+ curie_registry = CurieRegistry(curies=[{"name": "ex", "href": "https://example.com/{rel}"}])
117
+ expanded_uri = curie_registry.expand_curie("ex:some_relation")
118
+ compact_uri = curie_registry.compact_curie("https://example.com/some_relation")
119
+ ```
120
+
121
+ ### Error Handling
122
+
123
+ The library provides custom exceptions for common HTTP errors:
124
+
125
+ ```python
126
+ from halformsclient.exceptions import BadRequest, Unauthorized
127
+
128
+ try:
129
+ resource = client.follow_link(HALLink(uri="/some/invalid/resource"))
130
+ except BadRequest as e:
131
+ print(f"Bad request: {str(e)}")
132
+ except Unauthorized as e:
133
+ print(f"Unauthorized: {str(e)}")
134
+ ```
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ requirements.txt
5
+ setup.py
6
+ src/contentgrid_hal_client/__init__.py
7
+ src/contentgrid_hal_client/exceptions.py
8
+ src/contentgrid_hal_client/hal.py
9
+ src/contentgrid_hal_client/token_utils.py
10
+ src/contentgrid_hal_client.egg-info/PKG-INFO
11
+ src/contentgrid_hal_client.egg-info/SOURCES.txt
12
+ src/contentgrid_hal_client.egg-info/dependency_links.txt
13
+ src/contentgrid_hal_client.egg-info/requires.txt
14
+ src/contentgrid_hal_client.egg-info/top_level.txt
15
+ tests/test_hal.py
@@ -0,0 +1,2 @@
1
+ requests==2.31.0
2
+ uri-template==1.3.0
@@ -0,0 +1,163 @@
1
+ import os
2
+ from dotenv import load_dotenv
3
+ import pytest
4
+ from contentgrid_hal_client import HALFormsClient, HALResponse, InteractiveHALResponse, HALLink, CurieRegistry
5
+ from unittest.mock import patch, MagicMock
6
+
7
+ load_dotenv(override=True)
8
+ load_dotenv(".env.secret", override=True)
9
+
10
+ CONTENTGRID_CLIENT_ENDPOINT = os.getenv("CONTENTGRID_CLIENT_ENDPOINT")
11
+ CONTENTGRID_AUTH_URI = os.getenv("CONTENTGRID_AUTH_URI")
12
+
13
+ # Service account
14
+ CONTENTGRID_CLIENT_ID = os.getenv("CONTENTGRID_CLIENT_ID")
15
+ CONTENTGRID_CLIENT_SECRET = os.getenv("CONTENTGRID_CLIENT_SECRET")
16
+
17
+
18
+ @pytest.fixture
19
+ def hal_client():
20
+ return HALFormsClient(
21
+ client_endpoint=CONTENTGRID_CLIENT_ENDPOINT,
22
+ auth_uri=CONTENTGRID_AUTH_URI,
23
+ client_id=CONTENTGRID_CLIENT_ID,
24
+ client_secret=CONTENTGRID_CLIENT_SECRET,
25
+ )
26
+
27
+
28
+ def test_hal_client_initialization(hal_client: HALFormsClient):
29
+ assert hal_client.client_endpoint == CONTENTGRID_CLIENT_ENDPOINT
30
+ assert hal_client.auth_uri == CONTENTGRID_AUTH_URI
31
+ assert hal_client.client_id == CONTENTGRID_CLIENT_ID
32
+ assert hal_client.client_secret == CONTENTGRID_CLIENT_SECRET
33
+ assert hal_client.headers["Accept"] == "application/prs.hal-forms+json"
34
+
35
+
36
+ def test_hal_link():
37
+ link = HALLink(uri="https://api.example.com/resource", name="resource", title="Resource")
38
+ assert link.uri == "https://api.example.com/resource"
39
+ assert link.name == "resource"
40
+ assert link.title == "Resource"
41
+
42
+
43
+ def test_curie_registry():
44
+ curies = [{"name": "ex", "href": "https://api.example.com/rels/{rel}"}]
45
+ registry = CurieRegistry(curies)
46
+ expanded = registry.expand_curie("ex:resource")
47
+ assert expanded == "https://api.example.com/rels/resource"
48
+ compacted = registry.compact_curie("https://api.example.com/rels/resource")
49
+ assert compacted == "ex:resource"
50
+
51
+
52
+ def test_hal_response_initialization():
53
+ data = {
54
+ "_links": {
55
+ "self": {"href": "https://api.example.com/resource"},
56
+ "curies": [{"name": "ex", "href": "https://api.example.com/rels/{rel}"}]
57
+ },
58
+ "_embedded": {},
59
+ "_templates": {},
60
+ "metadata": "value"
61
+ }
62
+ response = HALResponse(data)
63
+ assert response.data == data
64
+ assert response.links == data["_links"]
65
+ assert response.embedded == data["_embedded"]
66
+ assert response.templates == data["_templates"]
67
+ assert response.metadata == {"metadata": "value"}
68
+
69
+
70
+ def test_hal_response_has_link():
71
+ data = {
72
+ "_links": {
73
+ "self": {"href": "https://api.example.com/resource"}
74
+ }
75
+ }
76
+ response = HALResponse(data)
77
+ assert response.has_link("self") is True
78
+ assert response.has_link("nonexistent") is False
79
+
80
+
81
+ def test_hal_response_get_link():
82
+ data = {
83
+ "_links": {
84
+ "self": {"href": "https://api.example.com/resource"}
85
+ }
86
+ }
87
+ response = HALResponse(data)
88
+ link = response.get_link("self")
89
+ assert link.uri == "https://api.example.com/resource"
90
+ assert response.get_link("nonexistent") is None
91
+
92
+
93
+ def test_hal_response_get_links():
94
+ data = {
95
+ "_links": {
96
+ "self": {"href": "https://api.example.com/resource"},
97
+ "multiple": [
98
+ {"href": "https://api.example.com/resource1"},
99
+ {"href": "https://api.example.com/resource2"}
100
+ ]
101
+ }
102
+ }
103
+ response = HALResponse(data)
104
+ links = response.get_links("multiple")
105
+ assert len(links) == 2
106
+ assert links[0].uri == "https://api.example.com/resource1"
107
+ assert links[1].uri == "https://api.example.com/resource2"
108
+
109
+
110
+ @patch.object(HALFormsClient, 'request', return_value=MagicMock(status_code=200, json=lambda: {}))
111
+ def test_interactive_hal_response_refetch(mock_request, hal_client: HALFormsClient):
112
+ data = {
113
+ "_links": {
114
+ "self": {"href": "https://api.example.com/resource"}
115
+ }
116
+ }
117
+ response = InteractiveHALResponse(data, hal_client)
118
+ response.refetch()
119
+ assert mock_request.called
120
+ assert mock_request.call_args[0] == ('GET', 'https://api.example.com/resource')
121
+
122
+
123
+ @patch.object(HALFormsClient, 'request', return_value=MagicMock(status_code=204))
124
+ def test_interactive_hal_response_delete(mock_request, hal_client: HALFormsClient):
125
+ data = {
126
+ "_links": {
127
+ "self": {"href": "https://api.example.com/resource"}
128
+ }
129
+ }
130
+ response = InteractiveHALResponse(data, hal_client)
131
+ response.delete()
132
+ assert mock_request.called
133
+ assert mock_request.call_args[0] == ('DELETE', 'https://api.example.com/resource')
134
+
135
+
136
+ @patch.object(HALFormsClient, 'request', return_value=MagicMock(status_code=200, json=lambda: {}))
137
+ def test_interactive_hal_response_put_data(mock_request, hal_client: HALFormsClient):
138
+ data = {
139
+ "_links": {
140
+ "self": {"href": "https://api.example.com/resource"}
141
+ }
142
+ }
143
+ response = InteractiveHALResponse(data, hal_client)
144
+ new_data = {"key": "value"}
145
+ response.put_data(new_data)
146
+ assert mock_request.called
147
+ assert mock_request.call_args[0] == ('PUT', 'https://api.example.com/resource')
148
+ assert mock_request.call_args[1]['json'] == new_data
149
+
150
+
151
+ @patch.object(HALFormsClient, 'request', return_value=MagicMock(status_code=200, json=lambda: {}))
152
+ def test_interactive_hal_response_patch_data(mock_request, hal_client: HALFormsClient):
153
+ data = {
154
+ "_links": {
155
+ "self": {"href": "https://api.example.com/resource"}
156
+ }
157
+ }
158
+ response = InteractiveHALResponse(data, hal_client)
159
+ new_data = {"key": "value"}
160
+ response.patch_data(new_data)
161
+ assert mock_request.called
162
+ assert mock_request.call_args[0] == ('PATCH', 'https://api.example.com/resource')
163
+ assert mock_request.call_args[1]['json'] == new_data