fastapi-unirate 0.1.0__py3-none-any.whl
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.
- fastapi_unirate/__init__.py +22 -0
- fastapi_unirate/client.py +150 -0
- fastapi_unirate/dependencies.py +88 -0
- fastapi_unirate/middleware.py +217 -0
- fastapi_unirate/models.py +42 -0
- fastapi_unirate-0.1.0.dist-info/METADATA +183 -0
- fastapi_unirate-0.1.0.dist-info/RECORD +9 -0
- fastapi_unirate-0.1.0.dist-info/WHEEL +4 -0
- fastapi_unirate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""FastAPI integration for the UniRate currency-exchange API."""
|
|
2
|
+
|
|
3
|
+
from fastapi_unirate.client import UniRateAPIError, UniRateClient
|
|
4
|
+
from fastapi_unirate.dependencies import (
|
|
5
|
+
UniRateDep,
|
|
6
|
+
get_unirate_client,
|
|
7
|
+
unirate_lifespan,
|
|
8
|
+
)
|
|
9
|
+
from fastapi_unirate.middleware import CurrencyConversionMiddleware
|
|
10
|
+
from fastapi_unirate.models import Money
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CurrencyConversionMiddleware",
|
|
14
|
+
"Money",
|
|
15
|
+
"UniRateAPIError",
|
|
16
|
+
"UniRateClient",
|
|
17
|
+
"UniRateDep",
|
|
18
|
+
"get_unirate_client",
|
|
19
|
+
"unirate_lifespan",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Async UniRate API client used by the FastAPI dependency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://api.unirateapi.com"
|
|
10
|
+
DEFAULT_TIMEOUT = 30.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class UniRateAPIError(Exception):
|
|
14
|
+
"""Raised when the UniRate API returns a non-success response."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status_code = status_code
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UniRateClient:
|
|
22
|
+
"""Thin async client over the UniRate REST API.
|
|
23
|
+
|
|
24
|
+
Wraps an ``httpx.AsyncClient`` so it slots cleanly into FastAPI's async
|
|
25
|
+
request lifecycle. Use :func:`fastapi_unirate.unirate_lifespan` to manage
|
|
26
|
+
a single client per app, or instantiate directly inside tests.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
api_key: str,
|
|
32
|
+
*,
|
|
33
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
34
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
35
|
+
http_client: httpx.AsyncClient | None = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
if not api_key:
|
|
38
|
+
msg = "api_key is required"
|
|
39
|
+
raise ValueError(msg)
|
|
40
|
+
self.api_key = api_key
|
|
41
|
+
self.base_url = base_url.rstrip("/")
|
|
42
|
+
self.timeout = timeout
|
|
43
|
+
self._owns_client = http_client is None
|
|
44
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
45
|
+
|
|
46
|
+
async def aclose(self) -> None:
|
|
47
|
+
"""Close the underlying HTTP client if this instance owns it."""
|
|
48
|
+
if self._owns_client:
|
|
49
|
+
await self._client.aclose()
|
|
50
|
+
|
|
51
|
+
async def __aenter__(self) -> UniRateClient:
|
|
52
|
+
return self
|
|
53
|
+
|
|
54
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
55
|
+
await self.aclose()
|
|
56
|
+
|
|
57
|
+
async def _request(
|
|
58
|
+
self, path: str, params: dict[str, Any] | None = None
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
full_params: dict[str, Any] = {"api_key": self.api_key}
|
|
61
|
+
if params:
|
|
62
|
+
full_params.update(params)
|
|
63
|
+
try:
|
|
64
|
+
response = await self._client.get(
|
|
65
|
+
f"{self.base_url}{path}",
|
|
66
|
+
params=full_params,
|
|
67
|
+
headers={"Accept": "application/json"},
|
|
68
|
+
)
|
|
69
|
+
except httpx.HTTPError as exc: # pragma: no cover - network errors
|
|
70
|
+
raise UniRateAPIError(f"UniRate request failed: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
if response.status_code == 401:
|
|
73
|
+
raise UniRateAPIError("Missing or invalid UniRate API key", status_code=401)
|
|
74
|
+
if response.status_code == 403:
|
|
75
|
+
raise UniRateAPIError(
|
|
76
|
+
"Endpoint requires a UniRate Pro subscription", status_code=403
|
|
77
|
+
)
|
|
78
|
+
if response.status_code == 404:
|
|
79
|
+
raise UniRateAPIError(
|
|
80
|
+
"Currency not found or no data available", status_code=404
|
|
81
|
+
)
|
|
82
|
+
if response.status_code == 429:
|
|
83
|
+
raise UniRateAPIError("UniRate API rate limit exceeded", status_code=429)
|
|
84
|
+
if response.status_code >= 400:
|
|
85
|
+
raise UniRateAPIError(
|
|
86
|
+
f"UniRate API error: HTTP {response.status_code}",
|
|
87
|
+
status_code=response.status_code,
|
|
88
|
+
)
|
|
89
|
+
return response.json()
|
|
90
|
+
|
|
91
|
+
async def get_rate(
|
|
92
|
+
self, from_currency: str = "USD", to_currency: str | None = None
|
|
93
|
+
) -> float | dict[str, float]:
|
|
94
|
+
"""Return the latest rate, or a mapping of every target rate.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
from_currency: ISO 4217 base currency code.
|
|
98
|
+
to_currency: ISO 4217 target currency. If omitted, every supported
|
|
99
|
+
target is returned.
|
|
100
|
+
"""
|
|
101
|
+
params: dict[str, Any] = {"from": from_currency.upper()}
|
|
102
|
+
if to_currency is not None:
|
|
103
|
+
params["to"] = to_currency.upper()
|
|
104
|
+
data = await self._request("/api/rates", params)
|
|
105
|
+
if to_currency is not None:
|
|
106
|
+
return float(data["rate"])
|
|
107
|
+
return {code: float(rate) for code, rate in data["rates"].items()}
|
|
108
|
+
|
|
109
|
+
async def convert(
|
|
110
|
+
self,
|
|
111
|
+
from_currency: str,
|
|
112
|
+
to_currency: str,
|
|
113
|
+
amount: float = 1.0,
|
|
114
|
+
) -> float:
|
|
115
|
+
"""Convert ``amount`` from one currency to another at the latest rate."""
|
|
116
|
+
data = await self._request(
|
|
117
|
+
"/api/convert",
|
|
118
|
+
{
|
|
119
|
+
"from": from_currency.upper(),
|
|
120
|
+
"to": to_currency.upper(),
|
|
121
|
+
"amount": amount,
|
|
122
|
+
},
|
|
123
|
+
)
|
|
124
|
+
return float(data["result"])
|
|
125
|
+
|
|
126
|
+
async def get_supported_currencies(self) -> list[str]:
|
|
127
|
+
"""Return every supported ISO/ticker code the API can convert between."""
|
|
128
|
+
data = await self._request("/api/currencies")
|
|
129
|
+
return list(data["currencies"])
|
|
130
|
+
|
|
131
|
+
async def convert_historical(
|
|
132
|
+
self,
|
|
133
|
+
from_currency: str,
|
|
134
|
+
to_currency: str,
|
|
135
|
+
date: str,
|
|
136
|
+
amount: float = 1.0,
|
|
137
|
+
) -> float:
|
|
138
|
+
"""Convert at the rate observed on ``date`` (YYYY-MM-DD). Pro-gated."""
|
|
139
|
+
data = await self._request(
|
|
140
|
+
"/api/historical/rates",
|
|
141
|
+
{
|
|
142
|
+
"from": from_currency.upper(),
|
|
143
|
+
"to": to_currency.upper(),
|
|
144
|
+
"amount": amount,
|
|
145
|
+
"date": date,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
if "result" in data:
|
|
149
|
+
return float(data["result"])
|
|
150
|
+
return float(data["rate"])
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""FastAPI dependency wiring for :class:`UniRateClient`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
from fastapi import Depends, FastAPI, Request
|
|
11
|
+
|
|
12
|
+
from fastapi_unirate.client import UniRateClient
|
|
13
|
+
|
|
14
|
+
_STATE_KEY = "unirate_client"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def unirate_lifespan(
|
|
18
|
+
*,
|
|
19
|
+
api_key: str | None = None,
|
|
20
|
+
base_url: str | None = None,
|
|
21
|
+
timeout: float | None = None,
|
|
22
|
+
) -> Callable[[FastAPI], Any]:
|
|
23
|
+
"""Return a FastAPI ``lifespan`` callable that owns a single shared client.
|
|
24
|
+
|
|
25
|
+
Reads ``UNIRATE_API_KEY`` from the environment when ``api_key`` is not
|
|
26
|
+
passed.
|
|
27
|
+
|
|
28
|
+
Example:
|
|
29
|
+
.. code-block:: python
|
|
30
|
+
|
|
31
|
+
from fastapi import FastAPI
|
|
32
|
+
from fastapi_unirate import unirate_lifespan
|
|
33
|
+
|
|
34
|
+
app = FastAPI(lifespan=unirate_lifespan())
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
39
|
+
key = api_key or os.environ.get("UNIRATE_API_KEY")
|
|
40
|
+
if not key:
|
|
41
|
+
msg = (
|
|
42
|
+
"UniRate API key not set — pass api_key= to unirate_lifespan() "
|
|
43
|
+
"or set UNIRATE_API_KEY in the environment"
|
|
44
|
+
)
|
|
45
|
+
raise RuntimeError(msg)
|
|
46
|
+
kwargs: dict[str, Any] = {}
|
|
47
|
+
if base_url is not None:
|
|
48
|
+
kwargs["base_url"] = base_url
|
|
49
|
+
if timeout is not None:
|
|
50
|
+
kwargs["timeout"] = timeout
|
|
51
|
+
client = UniRateClient(api_key=key, **kwargs)
|
|
52
|
+
setattr(app.state, _STATE_KEY, client)
|
|
53
|
+
try:
|
|
54
|
+
yield
|
|
55
|
+
finally:
|
|
56
|
+
await client.aclose()
|
|
57
|
+
|
|
58
|
+
return _lifespan
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_unirate_client(request: Request) -> UniRateClient:
|
|
62
|
+
"""``Depends`` provider that returns the lifespan-managed client.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
RuntimeError: If the app was started without ``unirate_lifespan``
|
|
66
|
+
(or another wiring that puts a ``UniRateClient`` on
|
|
67
|
+
``app.state.unirate_client``).
|
|
68
|
+
"""
|
|
69
|
+
client = getattr(request.app.state, _STATE_KEY, None)
|
|
70
|
+
if client is None:
|
|
71
|
+
msg = (
|
|
72
|
+
"UniRateClient is not configured. Wire up the app with "
|
|
73
|
+
"fastapi_unirate.unirate_lifespan() or assign a client to "
|
|
74
|
+
"app.state.unirate_client manually."
|
|
75
|
+
)
|
|
76
|
+
raise RuntimeError(msg)
|
|
77
|
+
return client
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Convenience alias for path-operation signatures.
|
|
81
|
+
UniRateDep = Annotated[UniRateClient, Depends(get_unirate_client)]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
__all__ = [
|
|
85
|
+
"UniRateDep",
|
|
86
|
+
"get_unirate_client",
|
|
87
|
+
"unirate_lifespan",
|
|
88
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""ASGI middleware that auto-converts Money values in JSON responses.
|
|
2
|
+
|
|
3
|
+
Reads a target currency from a configurable query parameter (default
|
|
4
|
+
``?currency=``) and rewrites every ``Money``-shaped object in the response
|
|
5
|
+
JSON to that currency at the latest UniRate rate.
|
|
6
|
+
|
|
7
|
+
A ``Money``-shaped object is a dict (or list of dicts) with both ``amount``
|
|
8
|
+
(numeric) and ``currency`` (string) keys.
|
|
9
|
+
|
|
10
|
+
Conversions are done concurrently per response and cached for the duration
|
|
11
|
+
of the request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import json
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from fastapi import FastAPI
|
|
22
|
+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
|
23
|
+
|
|
24
|
+
from fastapi_unirate.client import UniRateAPIError, UniRateClient
|
|
25
|
+
|
|
26
|
+
_STATE_KEY = "unirate_client"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CurrencyConversionMiddleware:
|
|
30
|
+
"""Rewrite Money-shaped values in JSON responses to a request-scoped currency.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
app: The ASGI app to wrap.
|
|
34
|
+
query_param: Name of the query parameter that holds the target
|
|
35
|
+
currency (default ``"currency"``).
|
|
36
|
+
client: Override the lifespan-managed client (mostly for tests).
|
|
37
|
+
|
|
38
|
+
Behaviour:
|
|
39
|
+
- Only acts on ``application/json`` responses with a 2xx status.
|
|
40
|
+
- Skips when the query parameter is absent.
|
|
41
|
+
- Leaves the response untouched on any UniRate API error so the
|
|
42
|
+
handler's response still reaches the user.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
app: ASGIApp,
|
|
48
|
+
*,
|
|
49
|
+
query_param: str = "currency",
|
|
50
|
+
client: UniRateClient | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.app = app
|
|
53
|
+
self.query_param = query_param
|
|
54
|
+
self._override_client = client
|
|
55
|
+
|
|
56
|
+
def _resolve_client(self, scope: Scope) -> UniRateClient | None:
|
|
57
|
+
if self._override_client is not None:
|
|
58
|
+
return self._override_client
|
|
59
|
+
app: FastAPI | None = scope.get("app")
|
|
60
|
+
if app is None:
|
|
61
|
+
return None
|
|
62
|
+
return getattr(app.state, _STATE_KEY, None)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def _target_from_query(scope: Scope, key: str) -> str | None:
|
|
66
|
+
raw_qs = scope.get("query_string") or b""
|
|
67
|
+
if not raw_qs:
|
|
68
|
+
return None
|
|
69
|
+
from urllib.parse import parse_qs
|
|
70
|
+
|
|
71
|
+
parsed = parse_qs(raw_qs.decode("latin-1"))
|
|
72
|
+
values = parsed.get(key)
|
|
73
|
+
if not values:
|
|
74
|
+
return None
|
|
75
|
+
target = values[0].strip().upper()
|
|
76
|
+
return target or None
|
|
77
|
+
|
|
78
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
79
|
+
if scope["type"] != "http":
|
|
80
|
+
await self.app(scope, receive, send)
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
target = self._target_from_query(scope, self.query_param)
|
|
84
|
+
if target is None:
|
|
85
|
+
await self.app(scope, receive, send)
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
client = self._resolve_client(scope)
|
|
89
|
+
if client is None:
|
|
90
|
+
await self.app(scope, receive, send)
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
body = bytearray()
|
|
94
|
+
response_start: Message | None = None
|
|
95
|
+
content_type = ""
|
|
96
|
+
|
|
97
|
+
async def _send(message: Message) -> None:
|
|
98
|
+
nonlocal response_start, content_type
|
|
99
|
+
if message["type"] == "http.response.start":
|
|
100
|
+
response_start = message
|
|
101
|
+
for name, value in message.get("headers", []):
|
|
102
|
+
if name.decode("latin-1").lower() == "content-type":
|
|
103
|
+
content_type = value.decode("latin-1").lower()
|
|
104
|
+
break
|
|
105
|
+
# Defer sending the start until we know the rewritten body length.
|
|
106
|
+
return
|
|
107
|
+
if message["type"] == "http.response.body":
|
|
108
|
+
body.extend(message.get("body") or b"")
|
|
109
|
+
if message.get("more_body"):
|
|
110
|
+
return
|
|
111
|
+
# End of body — try to rewrite.
|
|
112
|
+
rewritten = await _maybe_rewrite(
|
|
113
|
+
bytes(body), content_type, client, target
|
|
114
|
+
)
|
|
115
|
+
if response_start is not None:
|
|
116
|
+
headers = [
|
|
117
|
+
(name, value)
|
|
118
|
+
for name, value in response_start.get("headers", [])
|
|
119
|
+
if name.decode("latin-1").lower() != "content-length"
|
|
120
|
+
]
|
|
121
|
+
headers.append(
|
|
122
|
+
(
|
|
123
|
+
b"content-length",
|
|
124
|
+
str(len(rewritten)).encode("latin-1"),
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
response_start["headers"] = headers
|
|
128
|
+
await send(response_start)
|
|
129
|
+
await send({"type": "http.response.body", "body": rewritten})
|
|
130
|
+
return
|
|
131
|
+
await send(message)
|
|
132
|
+
|
|
133
|
+
await self.app(scope, receive, _send)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
async def _maybe_rewrite(
|
|
137
|
+
body: bytes,
|
|
138
|
+
content_type: str,
|
|
139
|
+
client: UniRateClient,
|
|
140
|
+
target: str,
|
|
141
|
+
) -> bytes:
|
|
142
|
+
"""Rewrite Money values in a JSON body. Pass-through on any failure."""
|
|
143
|
+
if "application/json" not in content_type:
|
|
144
|
+
return body
|
|
145
|
+
if not body:
|
|
146
|
+
return body
|
|
147
|
+
try:
|
|
148
|
+
data = json.loads(body)
|
|
149
|
+
except (ValueError, UnicodeDecodeError):
|
|
150
|
+
return body
|
|
151
|
+
|
|
152
|
+
money_nodes = list(_iter_money_nodes(data))
|
|
153
|
+
if not money_nodes:
|
|
154
|
+
return body
|
|
155
|
+
|
|
156
|
+
cache: dict[tuple[str, float], float] = {}
|
|
157
|
+
try:
|
|
158
|
+
await asyncio.gather(
|
|
159
|
+
*(_rewrite_node(node, target, client, cache) for node in money_nodes)
|
|
160
|
+
)
|
|
161
|
+
except UniRateAPIError:
|
|
162
|
+
return body
|
|
163
|
+
|
|
164
|
+
return json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _iter_money_nodes(value: Any) -> Iterator[dict[str, Any]]:
|
|
168
|
+
"""Yield every dict that looks like a Money value, depth-first."""
|
|
169
|
+
if isinstance(value, dict):
|
|
170
|
+
if _is_money(value):
|
|
171
|
+
yield value
|
|
172
|
+
for v in value.values():
|
|
173
|
+
yield from _iter_money_nodes(v)
|
|
174
|
+
elif isinstance(value, list):
|
|
175
|
+
for item in value:
|
|
176
|
+
yield from _iter_money_nodes(item)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _is_money(value: dict[str, Any]) -> bool:
|
|
180
|
+
if "amount" not in value or "currency" not in value:
|
|
181
|
+
return False
|
|
182
|
+
amount = value["amount"]
|
|
183
|
+
currency = value["currency"]
|
|
184
|
+
if not isinstance(amount, (int, float)) or isinstance(amount, bool):
|
|
185
|
+
return False
|
|
186
|
+
if not isinstance(currency, str) or not currency:
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _rewrite_node(
|
|
192
|
+
node: dict[str, Any],
|
|
193
|
+
target: str,
|
|
194
|
+
client: UniRateClient,
|
|
195
|
+
cache: dict[tuple[str, float], float],
|
|
196
|
+
) -> None:
|
|
197
|
+
src_currency = str(node["currency"]).upper()
|
|
198
|
+
if src_currency == target:
|
|
199
|
+
node["currency"] = target
|
|
200
|
+
return
|
|
201
|
+
amount = float(node["amount"])
|
|
202
|
+
cache_key = (src_currency, amount)
|
|
203
|
+
if cache_key in cache:
|
|
204
|
+
node["amount"] = cache[cache_key]
|
|
205
|
+
node["currency"] = target
|
|
206
|
+
return
|
|
207
|
+
converted = await client.convert(
|
|
208
|
+
from_currency=src_currency,
|
|
209
|
+
to_currency=target,
|
|
210
|
+
amount=amount,
|
|
211
|
+
)
|
|
212
|
+
cache[cache_key] = converted
|
|
213
|
+
node["amount"] = converted
|
|
214
|
+
node["currency"] = target
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
__all__ = ["CurrencyConversionMiddleware"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Pydantic ``Money`` type for response bodies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from fastapi_unirate.client import UniRateClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Money(BaseModel):
|
|
14
|
+
"""Amount + currency pair that the conversion middleware can rewrite.
|
|
15
|
+
|
|
16
|
+
The middleware looks for response objects shaped like ``{"amount": ...,
|
|
17
|
+
"currency": "..."}`` and rewrites them to a request-scoped target
|
|
18
|
+
currency. Use this type in your response models so OpenAPI schemas stay
|
|
19
|
+
accurate, but the middleware will also rewrite plain dicts of the same
|
|
20
|
+
shape.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(extra="forbid")
|
|
24
|
+
|
|
25
|
+
amount: float
|
|
26
|
+
currency: str = Field(..., min_length=3, max_length=10)
|
|
27
|
+
|
|
28
|
+
@field_validator("currency")
|
|
29
|
+
@classmethod
|
|
30
|
+
def _normalize_currency(cls, v: str) -> str:
|
|
31
|
+
return v.upper()
|
|
32
|
+
|
|
33
|
+
async def convert_to(self, client: UniRateClient, currency: str) -> Money:
|
|
34
|
+
"""Return a new ``Money`` converted into ``currency`` at the latest rate."""
|
|
35
|
+
if currency.upper() == self.currency:
|
|
36
|
+
return self
|
|
37
|
+
new_amount = await client.convert(
|
|
38
|
+
from_currency=self.currency,
|
|
39
|
+
to_currency=currency,
|
|
40
|
+
amount=self.amount,
|
|
41
|
+
)
|
|
42
|
+
return Money(amount=new_amount, currency=currency.upper())
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-unirate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI integration for the UniRate currency-exchange API — async dependency, lifespan manager, Money Pydantic type, and response-conversion middleware.
|
|
5
|
+
Project-URL: Homepage, https://github.com/UniRate-API/fastapi-unirate
|
|
6
|
+
Project-URL: Repository, https://github.com/UniRate-API/fastapi-unirate
|
|
7
|
+
Project-URL: Issues, https://github.com/UniRate-API/fastapi-unirate/issues
|
|
8
|
+
Project-URL: Provider, https://unirateapi.com
|
|
9
|
+
Author: Unirate Team
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: currency,exchange-rates,fastapi,fintech,forex,money,unirate
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
25
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
26
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
27
|
+
Requires-Python: >=3.9
|
|
28
|
+
Requires-Dist: fastapi<2.0,>=0.100
|
|
29
|
+
Requires-Dist: httpx<1.0,>=0.25
|
|
30
|
+
Requires-Dist: pydantic<3.0,>=2.0
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# fastapi-unirate
|
|
34
|
+
|
|
35
|
+
[](https://pypi.org/project/fastapi-unirate/)
|
|
36
|
+
[](https://pypi.org/project/fastapi-unirate/)
|
|
37
|
+
[](https://github.com/UniRate-API/fastapi-unirate/blob/main/LICENSE)
|
|
38
|
+
|
|
39
|
+
FastAPI integration for the [UniRate](https://unirateapi.com) currency-exchange API:
|
|
40
|
+
|
|
41
|
+
- **Async dependency** — `Depends(get_unirate_client)` plus a typed
|
|
42
|
+
`UniRateDep` alias for path-operation signatures.
|
|
43
|
+
- **Lifespan helper** — one shared `httpx.AsyncClient`-backed UniRate client
|
|
44
|
+
per app, opened on startup and closed on shutdown.
|
|
45
|
+
- **`Money` Pydantic type** — amount + currency pair that round-trips
|
|
46
|
+
through OpenAPI schemas.
|
|
47
|
+
- **`CurrencyConversionMiddleware`** — auto-rewrites Money-shaped values in
|
|
48
|
+
JSON responses to a request-scoped target currency
|
|
49
|
+
(`?currency=EUR`).
|
|
50
|
+
|
|
51
|
+
UniRate covers 593+ fiat, crypto, and commodity codes. Latest rates and
|
|
52
|
+
conversion are on the free tier; historical endpoints
|
|
53
|
+
(`convert_historical`) require Pro.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
pip install fastapi-unirate
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or with uv / Poetry:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv add fastapi-unirate
|
|
65
|
+
poetry add fastapi-unirate
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import os
|
|
72
|
+
|
|
73
|
+
from fastapi import FastAPI
|
|
74
|
+
from fastapi_unirate import (
|
|
75
|
+
CurrencyConversionMiddleware,
|
|
76
|
+
Money,
|
|
77
|
+
UniRateDep,
|
|
78
|
+
unirate_lifespan,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
app = FastAPI(lifespan=unirate_lifespan()) # reads UNIRATE_API_KEY from env
|
|
82
|
+
app.add_middleware(CurrencyConversionMiddleware)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.get("/products/widget")
|
|
86
|
+
async def get_widget() -> dict[str, Money]:
|
|
87
|
+
return {"price": Money(amount=19.99, currency="USD")}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/rate/{base}/{quote}")
|
|
91
|
+
async def rate(base: str, quote: str, client: UniRateDep) -> dict[str, float]:
|
|
92
|
+
return {"rate": await client.get_rate(base, quote)}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
$ curl localhost:8000/products/widget
|
|
99
|
+
{"price":{"amount":19.99,"currency":"USD"}}
|
|
100
|
+
|
|
101
|
+
$ curl localhost:8000/products/widget?currency=EUR
|
|
102
|
+
{"price":{"amount":18.42,"currency":"EUR"}}
|
|
103
|
+
|
|
104
|
+
$ curl localhost:8000/rate/USD/JPY
|
|
105
|
+
{"rate":151.83}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The middleware finds every `{"amount": <number>, "currency": "<code>"}`
|
|
109
|
+
shape — top-level, nested, or inside lists — and rewrites it. Conversions
|
|
110
|
+
are de-duplicated per request and run concurrently.
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
`unirate_lifespan` accepts overrides:
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
app = FastAPI(
|
|
118
|
+
lifespan=unirate_lifespan(
|
|
119
|
+
api_key="...", # default: $UNIRATE_API_KEY
|
|
120
|
+
base_url="https://api.unirateapi.com", # default: production
|
|
121
|
+
timeout=15.0, # default: 30s
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`CurrencyConversionMiddleware` accepts the query-parameter name and an
|
|
127
|
+
explicit client (mostly for testing):
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
app.add_middleware(
|
|
131
|
+
CurrencyConversionMiddleware,
|
|
132
|
+
query_param="display_currency",
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Why a middleware?
|
|
137
|
+
|
|
138
|
+
The dossier sketched two integration shapes — DI and middleware — and
|
|
139
|
+
both pull their weight:
|
|
140
|
+
|
|
141
|
+
- The DI provider is the right tool when a handler explicitly wants to
|
|
142
|
+
call `convert()` or `get_rate()`.
|
|
143
|
+
- The middleware is the right tool when a service has a stable
|
|
144
|
+
`Money`-shaped response and wants to expose
|
|
145
|
+
*one* `?currency=` knob to clients without rewriting every handler.
|
|
146
|
+
|
|
147
|
+
You can use either, or both together (as in the quick-start example).
|
|
148
|
+
|
|
149
|
+
## Errors
|
|
150
|
+
|
|
151
|
+
The client raises `UniRateAPIError` on non-2xx responses (with
|
|
152
|
+
`status_code` set). Common cases:
|
|
153
|
+
|
|
154
|
+
| HTTP | Meaning |
|
|
155
|
+
|------|-------------------------------------------------|
|
|
156
|
+
| 401 | Missing or invalid API key |
|
|
157
|
+
| 403 | Pro subscription required (historical, commodities) |
|
|
158
|
+
| 404 | Currency not found |
|
|
159
|
+
| 429 | Rate limit exceeded |
|
|
160
|
+
|
|
161
|
+
The middleware swallows `UniRateAPIError` and passes the original
|
|
162
|
+
response through unchanged — so an upstream UniRate outage never becomes
|
|
163
|
+
a 500 on your service.
|
|
164
|
+
|
|
165
|
+
## Compatibility
|
|
166
|
+
|
|
167
|
+
- Python 3.9 – 3.13
|
|
168
|
+
- FastAPI ≥ 0.100
|
|
169
|
+
- Pydantic ≥ 2.0
|
|
170
|
+
|
|
171
|
+
## Related
|
|
172
|
+
|
|
173
|
+
- [`unirate-api`](https://pypi.org/project/unirate-api/) — sync UniRate
|
|
174
|
+
Python client (this package vendors a small async equivalent so it
|
|
175
|
+
doesn't pull in `requests`).
|
|
176
|
+
- [`langchain-unirate`](https://pypi.org/project/langchain-unirate/) —
|
|
177
|
+
LangChain partner package.
|
|
178
|
+
- Other UniRate integrations: dbt, Airflow, n8n, Raycast, MCP server.
|
|
179
|
+
Full list at <https://unirateapi.com>.
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
fastapi_unirate/__init__.py,sha256=sRYTKHrL8IMfzwvwH-2DjKiKZEddQfZmTZpNKdDKY_w,547
|
|
2
|
+
fastapi_unirate/client.py,sha256=MK5n_m12__s6xmOKULOqK_U4EQ1x7HwIyNjM_-6GBpg,5168
|
|
3
|
+
fastapi_unirate/dependencies.py,sha256=EcGK_h5uqezfYe4xp6vkgaAJwEUqcAcWUE0Rp8QHtl0,2552
|
|
4
|
+
fastapi_unirate/middleware.py,sha256=w-68UbCSGnxGjO7HPN7HOCY7hiTZ_WKgUcprB_ekheo,7049
|
|
5
|
+
fastapi_unirate/models.py,sha256=SLgKc-U5mtmAETyXYUX8144AdVfleziTqJCXGVziMLs,1391
|
|
6
|
+
fastapi_unirate-0.1.0.dist-info/METADATA,sha256=FI6jgosE0FVyp1-nohsEYCL6uLTs6f8ymeKc6-LMARw,5945
|
|
7
|
+
fastapi_unirate-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
8
|
+
fastapi_unirate-0.1.0.dist-info/licenses/LICENSE,sha256=rVrKGfIoSe_67p0Q3sfLhO-3hEJyzoTse2LtLvrD04c,1068
|
|
9
|
+
fastapi_unirate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UniRate API
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|