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.
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/fastapi-unirate.svg)](https://pypi.org/project/fastapi-unirate/)
36
+ [![Python](https://img.shields.io/pypi/pyversions/fastapi-unirate.svg)](https://pypi.org/project/fastapi-unirate/)
37
+ [![License](https://img.shields.io/pypi/l/fastapi-unirate.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.