Skip to content

Commit f7df3ed

Browse files
committed
Enhance SimpleJsonApiClient documentation and error handling; add examples for usage, headers, and query parameters
1 parent c03f7f5 commit f7df3ed

3 files changed

Lines changed: 192 additions & 60 deletions

File tree

docs/api.md

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,94 @@
22

33
## `SimpleJsonApiClient`
44

5-
TODO
5+
### Simple example
6+
7+
```python
8+
from tna_utilities.flask.api import SimpleJsonApiClient
9+
10+
# Create an API client with a base URL
11+
client = SimpleJsonApiClient("https://wagtail.nationalarchives.gov.uk/api/v2")
12+
13+
# Get the data from the /pages/ endpoint
14+
pages = client.get("pages")
15+
16+
# Get the data from the /global-notifications/ endpoint
17+
global_notifications = client.get("global-notifications")
18+
```
19+
20+
### Handling errors
21+
22+
```python
23+
from tna_utilities.flask.api import SimpleJsonApiClient
24+
25+
client = SimpleJsonApiClient("https://wagtail.nationalarchives.gov.uk/api/v2")
26+
27+
try:
28+
pages = client.get("pages")
29+
except Exception as error:
30+
print(f"An error occured with the API: {error}")
31+
pages = []
32+
```
33+
34+
You can catch and handle some of the more common exceptions:
35+
36+
- `tna_utilities.flask.api.ResourceForbidden`
37+
- `tna_utilities.flask.api.ResourceNotFound`
38+
- `tna_utilities.flask.api.ResourceUnauthorized`
39+
40+
### Headers
41+
42+
```python
43+
from tna_utilities.flask.api import SimpleJsonApiClient
44+
45+
# Set a default header for any request from the client
46+
client = SimpleJsonApiClient(
47+
"https://wagtail.nationalarchives.gov.uk/api/v2",
48+
default_headers={
49+
"Host": "my.test.client.com"
50+
}
51+
)
52+
53+
# Append a default header to all requests
54+
client.add_header("Authorization", "Token abc123")
55+
56+
# Add a specific header to the GET request
57+
# Host: my.test.client.com
58+
# Authorization: Token abc123
59+
# Pragma: no-cache
60+
pages = client.get(
61+
"pages",
62+
headers={
63+
"Pragma": "no-cache"
64+
}
65+
)
66+
67+
# Host: my.test.client.com
68+
# Authorization: Token abc123
69+
global_notifications = client.get("global-notifications")
70+
```
71+
72+
### Query parameters
73+
74+
```python
75+
from tna_utilities.flask.api import SimpleJsonApiClient
76+
77+
# Append a default query parameter to all requests
78+
client = SimpleJsonApiClient(
79+
"https://wagtail.nationalarchives.gov.uk/api/v2",
80+
default_params={
81+
"format": "json"
82+
}
83+
)
84+
85+
# Append a default query parameter to all requests
86+
client.add_parameter("limit", "100")
87+
88+
# https://wagtail.nationalarchives.gov.uk/api/v2/pages/?format=json&limit=100&offset=400
89+
pages = client.get(
90+
"pages",
91+
params={
92+
"offset": "400"
93+
}
94+
)
95+
```

tests/test_api.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import json
22
from unittest import TestCase, mock
33

4-
from tna_utilities.api import ResourceForbidden, ResourceNotFound, SimpleJsonApiClient
4+
from requests import Timeout
5+
from tna_utilities.api import (
6+
ResourceForbidden,
7+
ResourceNotFound,
8+
ResourceUnauthorized,
9+
SimpleJsonApiClient,
10+
)
511

612
MOCK_API_BASE_URL = "http://mockapi.com/"
713

@@ -22,10 +28,14 @@ def json(self):
2228
return MockResponse({"foo": "bar"}, 200)
2329
elif args[0] == f"{MOCK_API_BASE_URL}badrequest":
2430
return MockResponse(None, 400)
31+
elif args[0] == f"{MOCK_API_BASE_URL}unauthorized":
32+
return MockResponse(None, 401)
2533
elif args[0] == f"{MOCK_API_BASE_URL}forbidden":
2634
return MockResponse(None, 403)
2735
elif args[0] == f"{MOCK_API_BASE_URL}servererror":
2836
return MockResponse(None, 500)
37+
elif args[0] == f"{MOCK_API_BASE_URL}timeout":
38+
raise Timeout("Request timed out")
2939

3040
return MockResponse(None, 404)
3141

@@ -62,12 +72,6 @@ def test_happy(self, mock_get, mock_post):
6272
response = client.get("/happy")
6373
self.assertEqual(type(response), dict)
6474
self.assertDictEqual(response, {"foo": "bar"})
65-
# called_mock_get_urls = [call.args[0] for call in mock_get.call_args_list]
66-
# self.assertIn(
67-
# f"{MOCK_API_BASE_URL}happy",
68-
# called_mock_get_urls,
69-
# )
70-
# self.assertEqual(len(called_mock_get_urls), 1)
7175

7276
@mock.patch("requests.get", side_effect=mocked_requests_get)
7377
@mock.patch("requests.post", side_effect=mocked_requests_post)
@@ -83,13 +87,27 @@ def test_not_found(self, mock_get, mock_post):
8387
with self.assertRaises(ResourceNotFound):
8488
client.get("/notfound")
8589

90+
@mock.patch("requests.get", side_effect=mocked_requests_get)
91+
@mock.patch("requests.post", side_effect=mocked_requests_post)
92+
def test_resource_unauthorized(self, mock_get, mock_post):
93+
client = SimpleJsonApiClient(MOCK_API_BASE_URL)
94+
with self.assertRaises(ResourceUnauthorized):
95+
client.get("/unauthorized")
96+
8697
@mock.patch("requests.get", side_effect=mocked_requests_get)
8798
@mock.patch("requests.post", side_effect=mocked_requests_post)
8899
def test_resource_forbidden(self, mock_get, mock_post):
89100
client = SimpleJsonApiClient(MOCK_API_BASE_URL)
90101
with self.assertRaises(ResourceForbidden):
91102
client.get("/forbidden")
92103

104+
@mock.patch("requests.get", side_effect=mocked_requests_get)
105+
@mock.patch("requests.post", side_effect=mocked_requests_post)
106+
def test_resource_timeout(self, mock_get, mock_post):
107+
client = SimpleJsonApiClient(MOCK_API_BASE_URL)
108+
with self.assertRaises(Timeout):
109+
client.get("/timeout")
110+
93111
@mock.patch("requests.get", side_effect=mocked_requests_get)
94112
@mock.patch("requests.post", side_effect=mocked_requests_post)
95113
def test_other_exception(self, mock_get, mock_post):

tna_utilities/api.py

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import requests
2-
from requests import JSONDecodeError, Response, Timeout, TooManyRedirects, codes
2+
from requests import JSONDecodeError, Response, codes
33

44

55
class ResourceNotFound(Exception):
@@ -10,6 +10,10 @@ class ResourceForbidden(Exception):
1010
pass
1111

1212

13+
class ResourceUnauthorized(Exception):
14+
pass
15+
16+
1317
class SimpleJsonApiClient:
1418
"""
1519
A simple JSON API client that provides basic functionality for making GET requests to a specified API endpoint.
@@ -19,101 +23,119 @@ class SimpleJsonApiClient:
1923
The client also includes error handling for connection issues, timeouts, and non-JSON responses.
2024
2125
:param api_url: The base URL of the API.
22-
:param defaultHeaders: Optional dictionary of default headers to include in every request.
23-
:param defaultParams: Optional dictionary of default parameters to include in every request.
26+
:param default_headers: Optional dictionary of default headers to include in every request.
27+
:param default_params: Optional dictionary of default parameters to include in every request.
2428
"""
2529

2630
def __init__(
27-
self, api_url: str, defaultHeaders: dict = {}, defaultParams: dict = {}
31+
self, api_url: str, default_headers: dict = {}, default_params: dict = {}
2832
):
2933
self.api_url: str = api_url.rstrip("/")
3034
self.headers: dict = (
3135
{
3236
"Cache-Control": "no-cache",
3337
"Accept": "application/json",
3438
}
35-
if defaultHeaders
36-
else defaultHeaders
39+
if default_headers
40+
else default_headers
3741
)
38-
self.params: dict = defaultParams
42+
self.params: dict = default_params
3943

40-
def add_parameter(self, key: str, value):
44+
def add_default_parameter(self, key: str, value) -> "SimpleJsonApiClient":
4145
"""
42-
Add a single parameter to the request.
46+
Add a single default parameter to the requests.
4347
"""
4448

4549
self.params[key] = value
50+
return self
4651

47-
def add_parameters(self, params: dict):
52+
def add_default_parameters(self, params: dict) -> "SimpleJsonApiClient":
4853
"""
49-
Add multiple parameters to the request.
54+
Add multiple default parameters to the requests.
5055
"""
5156

5257
self.params = self.params | params
58+
return self
5359

54-
def add_header(self, key: str, value):
60+
def add_default_header(self, key: str, value) -> "SimpleJsonApiClient":
5561
"""
56-
Add a single header to the request.
62+
Add a single default header to the requests.
5763
"""
5864

5965
self.headers[key] = value
66+
return self
6067

61-
def add_headers(self, headers: dict):
68+
def add_default_headers(self, headers: dict) -> "SimpleJsonApiClient":
6269
"""
63-
Add multiple headers to the request.
70+
Add multiple default headers to the requests.
6471
"""
6572

6673
self.headers = self.headers | headers
74+
return self
75+
76+
def _normalise_url(self, path: str) -> str:
77+
"""
78+
Normalise a URL, avoiding duplicated slashes
79+
"""
6780

68-
def get(self, path: str = "/"):
81+
return f"{self.api_url}/{path.lstrip('/')}"
82+
83+
def get(
84+
self,
85+
path: str = "/",
86+
params: dict | None = None,
87+
headers: dict | None = None,
88+
timeout: int = 10,
89+
) -> dict:
6990
"""
7091
Make a GET request to the specified path of the API endpoint.
92+
93+
:param path: The path to append to the base API URL for the request.
94+
:param params: Optional dictionary of query parameters to include in the request. These will be merged with any default parameters set for the client.
95+
:param headers: Optional dictionary of headers to include in the request. These will be merged with any default headers set for the client.
7196
"""
7297

73-
url = f"{self.api_url}/{path.lstrip('/')}"
74-
try:
75-
response = requests.get(
76-
url,
77-
params=self.params,
78-
headers=self.headers,
79-
)
80-
except ConnectionError:
81-
raise Exception("A connection error occured")
82-
except Timeout:
83-
raise Exception("The request timed out")
84-
except TooManyRedirects:
85-
raise Exception("Too many redirects")
86-
except Exception as e:
87-
raise Exception(e)
98+
url = self._normalise_url(path)
99+
response = requests.get(
100+
url,
101+
params=self.params if params is None else {**self.params, **params},
102+
headers=self.headers if headers is None else {**self.headers, **headers},
103+
timeout=timeout,
104+
)
88105
return self._handle_response(response)
89106

90107
def post(
91-
self, path: str = "/", data: dict | None = None, json: dict | str | None = None
92-
):
108+
self,
109+
path: str = "/",
110+
data: dict | None = None,
111+
json: dict | str | None = None,
112+
params: dict | None = None,
113+
headers: dict | None = None,
114+
timeout: int = 10,
115+
) -> dict:
93116
"""
94117
Make a POST request to the specified path of the API endpoint.
118+
119+
:param path: The path to append to the base API URL for the request.
120+
:param data: Optional dictionary, list of tuples, bytes, or file-like
121+
object to include in the request body.
122+
:param json: Optional JSON serialisable Python object to send in the request body.
123+
:param params: Optional dictionary of query parameters to include in the request. These will be merged with any default parameters set for the client.
124+
:param headers: Optional dictionary of headers to include in the request. These will be merged with any default headers set for the client.
95125
"""
96126

97-
url = f"{self.api_url}/{path.lstrip('/')}"
98-
try:
99-
response = requests.post(
100-
url,
101-
params=self.params,
102-
headers=self.headers,
103-
data=data,
104-
json=json,
105-
)
106-
except ConnectionError:
107-
raise Exception("A connection error occured")
108-
except Timeout:
109-
raise Exception("The request timed out")
110-
except TooManyRedirects:
111-
raise Exception("Too many redirects")
112-
except Exception as e:
113-
raise Exception(e)
127+
url = self._normalise_url(path)
128+
response = requests.post(
129+
url,
130+
params=self.params if params is None else {**self.params, **params},
131+
headers=self.headers if headers is None else {**self.headers, **headers},
132+
data=data,
133+
json=json,
134+
timeout=timeout,
135+
)
114136
return self._handle_response(response)
115137

116-
def _handle_response(self, response: Response):
138+
def _handle_response(self, response: Response) -> dict:
117139
"""
118140
Handle the API response, checking for common HTTP status codes and returning the JSON content if the request was successful.
119141
"""
@@ -125,8 +147,10 @@ def _handle_response(self, response: Response):
125147
raise Exception("Non-JSON response provided")
126148
if response.status_code == 400:
127149
raise Exception("Bad request")
150+
if response.status_code == 401:
151+
raise ResourceUnauthorized("Unauthorised")
128152
if response.status_code == 403:
129153
raise ResourceForbidden("Forbidden")
130154
if response.status_code == 404:
131155
raise ResourceNotFound("Resource not found")
132-
raise Exception("Request failed")
156+
raise Exception(f"Request failed with {response.status_code}")

0 commit comments

Comments
 (0)