Skip to content

Commit 25ec00e

Browse files
committed
feat: add async support (httpx) and context manager support for all clients #5
1 parent 37018d7 commit 25ec00e

File tree

5 files changed

+301
-10
lines changed

5 files changed

+301
-10
lines changed

README.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ Before using the Openapi Python Client, you will need an account at [Openapi](ht
2626
## Features
2727

2828
- **Agnostic Design**: No API-specific classes, works with any OpenAPI service
29-
- **Minimal Dependencies**: Only requires Python 3.8+ and `requests`
29+
- **Minimal Dependencies**: Only requires Python 3.8+ and `httpx`
3030
- **OAuth Support**: Built-in OAuth client for token management
3131
- **HTTP Primitives**: GET, POST, PUT, DELETE, PATCH methods
32+
- **Async Support**: Fully compatible with async frameworks like FastAPI and aiohttp
3233
- **Clean Interface**: Similar to the Rust SDK design
3334

3435
## What you can do
@@ -70,7 +71,7 @@ Interaction with the Openapi platform happens in two distinct steps.
7071
Authenticate with your credentials and obtain a short-lived bearer token scoped to the endpoints you need.
7172

7273
```python
73-
from openapi_python_sdk.client import OauthClient
74+
from openapi_python_sdk import OauthClient
7475

7576
oauth = OauthClient(username="<your_username>", apikey="<your_apikey>", test=True)
7677

@@ -92,7 +93,7 @@ oauth.delete_token(id=token)
9293
Use the token to make authenticated requests to any Openapi service.
9394

9495
```python
95-
from openapi_python_sdk.client import Client
96+
from openapi_python_sdk import Client
9697

9798
client = Client(token=token)
9899

@@ -111,6 +112,37 @@ resp = client.request(
111112
)
112113
```
113114

115+
## Async Usage
116+
117+
The SDK provides `AsyncClient` and `AsyncOauthClient` for use with asynchronous frameworks like FastAPI or `aiohttp`.
118+
119+
### Async Authentication
120+
121+
```python
122+
from openapi_python_sdk import AsyncOauthClient
123+
124+
async with AsyncOauthClient(username="<your_username>", apikey="<your_apikey>", test=True) as oauth:
125+
resp = await oauth.create_token(
126+
scopes=["GET:test.imprese.openapi.it/advance"],
127+
ttl=3600,
128+
)
129+
token = resp["token"]
130+
```
131+
132+
### Async Requests
133+
134+
```python
135+
from openapi_python_sdk import AsyncClient
136+
137+
async with AsyncClient(token=token) as client:
138+
resp = await client.request(
139+
method="GET",
140+
url="https://test.imprese.openapi.it/advance",
141+
params={"denominazione": "altravia"},
142+
)
143+
```
144+
145+
114146
## Testing
115147

116148
Install dev dependencies and run the test suite:
@@ -174,4 +206,3 @@ The MIT License is a permissive open-source license that allows you to freely us
174206
In short, you are free to use this SDK in your personal, academic, or commercial projects, with minimal restrictions. The project is provided "as-is", without any warranty of any kind, either expressed or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, and non-infringement.
175207

176208
For more details, see the full license text at the [MIT License page](https://choosealicense.com/licenses/mit/).
177-

openapi-python-sdk

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Subproject commit 37018d7aebbaa11312900e19b5a23c854553ee8f

openapi_python_sdk/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""
2+
Openapi Python SDK - A minimal and agnostic SDK for the Openapi marketplace.
3+
Exports both synchronous and asynchronous clients.
4+
"""
5+
from .client import AsyncClient, AsyncOauthClient, Client, OauthClient
6+
7+
__all__ = ["Client", "AsyncClient", "OauthClient", "AsyncOauthClient"]

openapi_python_sdk/client.py

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import base64
2-
from typing import Any, Dict
2+
import json
3+
from typing import Any, Dict, List, Optional
34

45
import httpx
56

6-
import json
7-
87
OAUTH_BASE_URL = "https://oauth.openapi.it"
98
TEST_OAUTH_BASE_URL = "https://test.oauth.openapi.it"
109

1110

1211
class OauthClient:
12+
"""
13+
Synchronous client for handling Openapi authentication and token management.
14+
"""
15+
1316
def __init__(self, username: str, apikey: str, test: bool = False):
1417
self.client = httpx.Client()
1518
self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL
@@ -21,31 +24,115 @@ def __init__(self, username: str, apikey: str, test: bool = False):
2124
"Content-Type": "application/json",
2225
}
2326

27+
def __enter__(self):
28+
"""Enable use as a synchronous context manager."""
29+
return self
30+
31+
def __exit__(self, exc_type, exc_val, exc_tb):
32+
"""Ensure the underlying HTTP client is closed on exit."""
33+
self.client.close()
34+
35+
def close(self):
36+
"""Manually close the underlying HTTP client."""
37+
self.client.close()
38+
2439
def get_scopes(self, limit: bool = False) -> Dict[str, Any]:
40+
"""Retrieve available scopes for the current user."""
2541
params = {"limit": int(limit)}
2642
url = f"{self.url}/scopes"
2743
return self.client.get(url=url, headers=self.headers, params=params).json()
2844

29-
def create_token(self, scopes: list[str] = [], ttl: int = 0) -> Dict[str, Any]:
45+
def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]:
46+
"""Create a new bearer token with specified scopes and TTL."""
3047
payload = {"scopes": scopes, "ttl": ttl}
3148
url = f"{self.url}/token"
3249
return self.client.post(url=url, headers=self.headers, json=payload).json()
3350

3451
def get_token(self, scope: str = None) -> Dict[str, Any]:
52+
"""Retrieve an existing token, optionally filtered by scope."""
3553
params = {"scope": scope or ""}
3654
url = f"{self.url}/token"
3755
return self.client.get(url=url, headers=self.headers, params=params).json()
3856

3957
def delete_token(self, id: str) -> Dict[str, Any]:
58+
"""Revoke/Delete a specific token by ID."""
4059
url = f"{self.url}/token/{id}"
4160
return self.client.delete(url=url, headers=self.headers).json()
4261

4362
def get_counters(self, period: str, date: str) -> Dict[str, Any]:
63+
"""Retrieve usage counters for a specific period and date."""
4464
url = f"{self.url}/counters/{period}/{date}"
4565
return self.client.get(url=url, headers=self.headers).json()
4666

4767

68+
class AsyncOauthClient:
69+
"""
70+
Asynchronous client for handling Openapi authentication and token management.
71+
Suitable for use with FastAPI, aiohttp, etc.
72+
"""
73+
74+
def __init__(self, username: str, apikey: str, test: bool = False):
75+
self.client = httpx.AsyncClient()
76+
self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL
77+
self.auth_header: str = (
78+
"Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode()
79+
)
80+
self.headers: Dict[str, Any] = {
81+
"Authorization": self.auth_header,
82+
"Content-Type": "application/json",
83+
}
84+
85+
async def __aenter__(self):
86+
"""Enable use as an asynchronous context manager."""
87+
return self
88+
89+
async def __aexit__(self, exc_type, exc_val, exc_tb):
90+
"""Ensure the underlying HTTP client is closed on exit (async)."""
91+
await self.client.aclose()
92+
93+
async def aclose(self):
94+
"""Manually close the underlying HTTP client (async)."""
95+
await self.client.aclose()
96+
97+
async def get_scopes(self, limit: bool = False) -> Dict[str, Any]:
98+
"""Retrieve available scopes for the current user (async)."""
99+
params = {"limit": int(limit)}
100+
url = f"{self.url}/scopes"
101+
resp = await self.client.get(url=url, headers=self.headers, params=params)
102+
return resp.json()
103+
104+
async def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]:
105+
"""Create a new bearer token with specified scopes and TTL (async)."""
106+
payload = {"scopes": scopes, "ttl": ttl}
107+
url = f"{self.url}/token"
108+
resp = await self.client.post(url=url, headers=self.headers, json=payload)
109+
return resp.json()
110+
111+
async def get_token(self, scope: str = None) -> Dict[str, Any]:
112+
"""Retrieve an existing token, optionally filtered by scope (async)."""
113+
params = {"scope": scope or ""}
114+
url = f"{self.url}/token"
115+
resp = await self.client.get(url=url, headers=self.headers, params=params)
116+
return resp.json()
117+
118+
async def delete_token(self, id: str) -> Dict[str, Any]:
119+
"""Revoke/Delete a specific token by ID (async)."""
120+
url = f"{self.url}/token/{id}"
121+
resp = await self.client.delete(url=url, headers=self.headers)
122+
return resp.json()
123+
124+
async def get_counters(self, period: str, date: str) -> Dict[str, Any]:
125+
"""Retrieve usage counters for a specific period and date (async)."""
126+
url = f"{self.url}/counters/{period}/{date}"
127+
resp = await self.client.get(url=url, headers=self.headers)
128+
return resp.json()
129+
130+
48131
class Client:
132+
"""
133+
Synchronous client for making authenticated requests to Openapi endpoints.
134+
"""
135+
49136
def __init__(self, token: str):
50137
self.client = httpx.Client()
51138
self.auth_header: str = f"Bearer {token}"
@@ -54,13 +141,28 @@ def __init__(self, token: str):
54141
"Content-Type": "application/json",
55142
}
56143

144+
def __enter__(self):
145+
"""Enable use as a synchronous context manager."""
146+
return self
147+
148+
def __exit__(self, exc_type, exc_val, exc_tb):
149+
"""Ensure the underlying HTTP client is closed on exit."""
150+
self.client.close()
151+
152+
def close(self):
153+
"""Manually close the underlying HTTP client."""
154+
self.client.close()
155+
57156
def request(
58157
self,
59158
method: str = "GET",
60159
url: str = None,
61160
payload: Dict[str, Any] = None,
62161
params: Dict[str, Any] = None,
63162
) -> Dict[str, Any]:
163+
"""
164+
Make a synchronous HTTP request to the specified Openapi endpoint.
165+
"""
64166
payload = payload or {}
65167
params = params or {}
66168
url = url or ""
@@ -72,9 +174,69 @@ def request(
72174
params=params,
73175
).json()
74176

75-
if isinstance(data,str):
177+
# Handle cases where the API might return a JSON-encoded string instead of an object
178+
if isinstance(data, str):
179+
try:
180+
data = json.loads(data)
181+
except json.JSONDecodeError:
182+
pass
183+
184+
return data
185+
186+
187+
class AsyncClient:
188+
"""
189+
Asynchronous client for making authenticated requests to Openapi endpoints.
190+
Suitable for use with FastAPI, aiohttp, etc.
191+
"""
192+
193+
def __init__(self, token: str):
194+
self.client = httpx.AsyncClient()
195+
self.auth_header: str = f"Bearer {token}"
196+
self.headers: Dict[str, str] = {
197+
"Authorization": self.auth_header,
198+
"Content-Type": "application/json",
199+
}
200+
201+
async def __aenter__(self):
202+
"""Enable use as an asynchronous context manager."""
203+
return self
204+
205+
async def __aexit__(self, exc_type, exc_val, exc_tb):
206+
"""Ensure the underlying HTTP client is closed on exit (async)."""
207+
await self.client.aclose()
208+
209+
async def aclose(self):
210+
"""Manually close the underlying HTTP client (async)."""
211+
await self.client.aclose()
212+
213+
async def request(
214+
self,
215+
method: str = "GET",
216+
url: str = None,
217+
payload: Dict[str, Any] = None,
218+
params: Dict[str, Any] = None,
219+
) -> Dict[str, Any]:
220+
"""
221+
Make an asynchronous HTTP request to the specified Openapi endpoint.
222+
"""
223+
payload = payload or {}
224+
params = params or {}
225+
url = url or ""
226+
resp = await self.client.request(
227+
method=method,
228+
url=url,
229+
headers=self.headers,
230+
json=payload,
231+
params=params,
232+
)
233+
data = resp.json()
234+
235+
# Handle cases where the API might return a JSON-encoded string instead of an object
236+
if isinstance(data, str):
76237
try:
77-
data=json.loads(data)
238+
data = json.loads(data)
78239
except json.JSONDecodeError:
79240
pass
241+
80242
return data

0 commit comments

Comments
 (0)