diff --git a/README.md b/README.md index 5083a7f..39db3d5 100644 --- a/README.md +++ b/README.md @@ -26,9 +26,10 @@ Before using the Openapi Python Client, you will need an account at [Openapi](ht ## Features - **Agnostic Design**: No API-specific classes, works with any OpenAPI service -- **Minimal Dependencies**: Only requires Python 3.8+ and `requests` +- **Minimal Dependencies**: Only requires Python 3.8+ and `httpx` - **OAuth Support**: Built-in OAuth client for token management - **HTTP Primitives**: GET, POST, PUT, DELETE, PATCH methods +- **Async Support**: Fully compatible with async frameworks like FastAPI and aiohttp - **Clean Interface**: Similar to the Rust SDK design ## What you can do @@ -70,7 +71,7 @@ Interaction with the Openapi platform happens in two distinct steps. Authenticate with your credentials and obtain a short-lived bearer token scoped to the endpoints you need. ```python -from openapi_python_sdk.client import OauthClient +from openapi_python_sdk import OauthClient oauth = OauthClient(username="", apikey="", test=True) @@ -92,7 +93,7 @@ oauth.delete_token(id=token) Use the token to make authenticated requests to any Openapi service. ```python -from openapi_python_sdk.client import Client +from openapi_python_sdk import Client client = Client(token=token) @@ -111,6 +112,37 @@ resp = client.request( ) ``` +## Async Usage + +The SDK provides `AsyncClient` and `AsyncOauthClient` for use with asynchronous frameworks like FastAPI or `aiohttp`. + +### Async Authentication + +```python +from openapi_python_sdk import AsyncOauthClient + +async with AsyncOauthClient(username="", apikey="", test=True) as oauth: + resp = await oauth.create_token( + scopes=["GET:test.imprese.openapi.it/advance"], + ttl=3600, + ) + token = resp["token"] +``` + +### Async Requests + +```python +from openapi_python_sdk import AsyncClient + +async with AsyncClient(token=token) as client: + resp = await client.request( + method="GET", + url="https://test.imprese.openapi.it/advance", + params={"denominazione": "altravia"}, + ) +``` + + ## Testing 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 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. For more details, see the full license text at the [MIT License page](https://choosealicense.com/licenses/mit/). - diff --git a/openapi-python-sdk b/openapi-python-sdk new file mode 160000 index 0000000..37018d7 --- /dev/null +++ b/openapi-python-sdk @@ -0,0 +1 @@ +Subproject commit 37018d7aebbaa11312900e19b5a23c854553ee8f diff --git a/openapi_python_sdk/__init__.py b/openapi_python_sdk/__init__.py index e69de29..e3bbff2 100644 --- a/openapi_python_sdk/__init__.py +++ b/openapi_python_sdk/__init__.py @@ -0,0 +1,7 @@ +""" +Openapi Python SDK - A minimal and agnostic SDK for the Openapi marketplace. +Exports both synchronous and asynchronous clients. +""" +from .client import AsyncClient, AsyncOauthClient, Client, OauthClient + +__all__ = ["Client", "AsyncClient", "OauthClient", "AsyncOauthClient"] diff --git a/openapi_python_sdk/client.py b/openapi_python_sdk/client.py index 4268bda..27f830f 100644 --- a/openapi_python_sdk/client.py +++ b/openapi_python_sdk/client.py @@ -1,15 +1,18 @@ import base64 -from typing import Any, Dict +import json +from typing import Any, Dict, List, Optional import httpx -import json - OAUTH_BASE_URL = "https://oauth.openapi.it" TEST_OAUTH_BASE_URL = "https://test.oauth.openapi.it" class OauthClient: + """ + Synchronous client for handling Openapi authentication and token management. + """ + def __init__(self, username: str, apikey: str, test: bool = False): self.client = httpx.Client() 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): "Content-Type": "application/json", } + def __enter__(self): + """Enable use as a synchronous context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit.""" + self.client.close() + + def close(self): + """Manually close the underlying HTTP client.""" + self.client.close() + def get_scopes(self, limit: bool = False) -> Dict[str, Any]: + """Retrieve available scopes for the current user.""" params = {"limit": int(limit)} url = f"{self.url}/scopes" return self.client.get(url=url, headers=self.headers, params=params).json() - def create_token(self, scopes: list[str] = [], ttl: int = 0) -> Dict[str, Any]: + def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: + """Create a new bearer token with specified scopes and TTL.""" payload = {"scopes": scopes, "ttl": ttl} url = f"{self.url}/token" return self.client.post(url=url, headers=self.headers, json=payload).json() def get_token(self, scope: str = None) -> Dict[str, Any]: + """Retrieve an existing token, optionally filtered by scope.""" params = {"scope": scope or ""} url = f"{self.url}/token" return self.client.get(url=url, headers=self.headers, params=params).json() def delete_token(self, id: str) -> Dict[str, Any]: + """Revoke/Delete a specific token by ID.""" url = f"{self.url}/token/{id}" return self.client.delete(url=url, headers=self.headers).json() def get_counters(self, period: str, date: str) -> Dict[str, Any]: + """Retrieve usage counters for a specific period and date.""" url = f"{self.url}/counters/{period}/{date}" return self.client.get(url=url, headers=self.headers).json() +class AsyncOauthClient: + """ + Asynchronous client for handling Openapi authentication and token management. + Suitable for use with FastAPI, aiohttp, etc. + """ + + def __init__(self, username: str, apikey: str, test: bool = False): + self.client = httpx.AsyncClient() + self.url: str = TEST_OAUTH_BASE_URL if test else OAUTH_BASE_URL + self.auth_header: str = ( + "Basic " + base64.b64encode(f"{username}:{apikey}".encode("utf-8")).decode() + ) + self.headers: Dict[str, Any] = { + "Authorization": self.auth_header, + "Content-Type": "application/json", + } + + async def __aenter__(self): + """Enable use as an asynchronous context manager.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit (async).""" + await self.client.aclose() + + async def aclose(self): + """Manually close the underlying HTTP client (async).""" + await self.client.aclose() + + async def get_scopes(self, limit: bool = False) -> Dict[str, Any]: + """Retrieve available scopes for the current user (async).""" + params = {"limit": int(limit)} + url = f"{self.url}/scopes" + resp = await self.client.get(url=url, headers=self.headers, params=params) + return resp.json() + + async def create_token(self, scopes: List[str] = [], ttl: int = 0) -> Dict[str, Any]: + """Create a new bearer token with specified scopes and TTL (async).""" + payload = {"scopes": scopes, "ttl": ttl} + url = f"{self.url}/token" + resp = await self.client.post(url=url, headers=self.headers, json=payload) + return resp.json() + + async def get_token(self, scope: str = None) -> Dict[str, Any]: + """Retrieve an existing token, optionally filtered by scope (async).""" + params = {"scope": scope or ""} + url = f"{self.url}/token" + resp = await self.client.get(url=url, headers=self.headers, params=params) + return resp.json() + + async def delete_token(self, id: str) -> Dict[str, Any]: + """Revoke/Delete a specific token by ID (async).""" + url = f"{self.url}/token/{id}" + resp = await self.client.delete(url=url, headers=self.headers) + return resp.json() + + async def get_counters(self, period: str, date: str) -> Dict[str, Any]: + """Retrieve usage counters for a specific period and date (async).""" + url = f"{self.url}/counters/{period}/{date}" + resp = await self.client.get(url=url, headers=self.headers) + return resp.json() + + class Client: + """ + Synchronous client for making authenticated requests to Openapi endpoints. + """ + def __init__(self, token: str): self.client = httpx.Client() self.auth_header: str = f"Bearer {token}" @@ -54,6 +141,18 @@ def __init__(self, token: str): "Content-Type": "application/json", } + def __enter__(self): + """Enable use as a synchronous context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit.""" + self.client.close() + + def close(self): + """Manually close the underlying HTTP client.""" + self.client.close() + def request( self, method: str = "GET", @@ -61,6 +160,9 @@ def request( payload: Dict[str, Any] = None, params: Dict[str, Any] = None, ) -> Dict[str, Any]: + """ + Make a synchronous HTTP request to the specified Openapi endpoint. + """ payload = payload or {} params = params or {} url = url or "" @@ -72,9 +174,69 @@ def request( params=params, ).json() - if isinstance(data,str): + # Handle cases where the API might return a JSON-encoded string instead of an object + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + pass + + return data + + +class AsyncClient: + """ + Asynchronous client for making authenticated requests to Openapi endpoints. + Suitable for use with FastAPI, aiohttp, etc. + """ + + def __init__(self, token: str): + self.client = httpx.AsyncClient() + self.auth_header: str = f"Bearer {token}" + self.headers: Dict[str, str] = { + "Authorization": self.auth_header, + "Content-Type": "application/json", + } + + async def __aenter__(self): + """Enable use as an asynchronous context manager.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Ensure the underlying HTTP client is closed on exit (async).""" + await self.client.aclose() + + async def aclose(self): + """Manually close the underlying HTTP client (async).""" + await self.client.aclose() + + async def request( + self, + method: str = "GET", + url: str = None, + payload: Dict[str, Any] = None, + params: Dict[str, Any] = None, + ) -> Dict[str, Any]: + """ + Make an asynchronous HTTP request to the specified Openapi endpoint. + """ + payload = payload or {} + params = params or {} + url = url or "" + resp = await self.client.request( + method=method, + url=url, + headers=self.headers, + json=payload, + params=params, + ) + data = resp.json() + + # Handle cases where the API might return a JSON-encoded string instead of an object + if isinstance(data, str): try: - data=json.loads(data) + data = json.loads(data) except json.JSONDecodeError: pass + return data \ No newline at end of file diff --git a/tests/test_async_client.py b/tests/test_async_client.py new file mode 100644 index 0000000..d25454b --- /dev/null +++ b/tests/test_async_client.py @@ -0,0 +1,90 @@ +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from openapi_python_sdk.client import AsyncClient, AsyncOauthClient + + +class TestAsyncOauthClient(unittest.IsolatedAsyncioTestCase): + """ + Test suite for AsyncOauthClient using IsolatedAsyncioTestCase + which allows for native await calls in test methods. + """ + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_create_token(self, mock_httpx): + # Mocking the response and the post method + mock_resp = MagicMock() + mock_resp.json.return_value = {"token": "abc123"} + mock_httpx.return_value.post = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + # Testing the async context manager (__aenter__ / __aexit__) + async with AsyncOauthClient(username="user", apikey="key", test=True) as oauth: + resp = await oauth.create_token(scopes=["GET:test.example.com/api"], ttl=3600) + + self.assertEqual(resp["token"], "abc123") + mock_httpx.return_value.post.assert_called_once() + # Verify aclose was called by the context manager + mock_httpx.return_value.aclose.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_get_scopes(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"scopes": ["GET:test.example.com/api"]} + mock_httpx.return_value.get = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + oauth = AsyncOauthClient(username="user", apikey="key") + resp = await oauth.get_scopes() + + self.assertIn("scopes", resp) + # Manually closing the client as we didn't use the context manager here + await oauth.aclose() + mock_httpx.return_value.aclose.assert_called_once() + + +class TestAsyncClient(unittest.IsolatedAsyncioTestCase): + """ + Test suite for the generic AsyncClient. + """ + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_request_get(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"data": []} + mock_httpx.return_value.request = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + async with AsyncClient(token="abc123") as client: + resp = await client.request( + method="GET", + url="https://test.imprese.openapi.it/advance", + params={"denominazione": "altravia"}, + ) + + self.assertEqual(resp, {"data": []}) + mock_httpx.return_value.request.assert_called_once() + mock_httpx.return_value.aclose.assert_called_once() + + @patch("openapi_python_sdk.client.httpx.AsyncClient") + async def test_request_post(self, mock_httpx): + mock_resp = MagicMock() + mock_resp.json.return_value = {"result": "ok"} + mock_httpx.return_value.request = AsyncMock(return_value=mock_resp) + mock_httpx.return_value.aclose = AsyncMock() + + client = AsyncClient(token="abc123") + resp = await client.request( + method="POST", + url="https://test.postontarget.com/fields/country", + payload={"limit": 0, "query": {"country_code": "IT"}}, + ) + + self.assertEqual(resp["result"], "ok") + # Ensure cleanup + await client.aclose() + mock_httpx.return_value.aclose.assert_called_once() + + +if __name__ == "__main__": + unittest.main()