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.
- contentgrid_hal_client-0.0.1/LICENSE +13 -0
- contentgrid_hal_client-0.0.1/PKG-INFO +134 -0
- contentgrid_hal_client-0.0.1/README.md +107 -0
- contentgrid_hal_client-0.0.1/pyproject.toml +28 -0
- contentgrid_hal_client-0.0.1/requirements.txt +2 -0
- contentgrid_hal_client-0.0.1/setup.cfg +4 -0
- contentgrid_hal_client-0.0.1/setup.py +3 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client/__init__.py +1 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client/exceptions.py +25 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client/hal.py +362 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client/token_utils.py +81 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client.egg-info/PKG-INFO +134 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client.egg-info/SOURCES.txt +15 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client.egg-info/dependency_links.txt +1 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client.egg-info/requires.txt +2 -0
- contentgrid_hal_client-0.0.1/src/contentgrid_hal_client.egg-info/top_level.txt +1 -0
- contentgrid_hal_client-0.0.1/tests/test_hal.py +163 -0
|
@@ -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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
contentgrid_hal_client
|
|
@@ -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
|