Skip to content

Commit 588917e

Browse files
rob-brownccclaude
andcommitted
Initial release: djangorestframework-unirate v0.1.0
Django REST Framework integration for the UniRate currency-exchange API: - UniRateAccessor: cached, settings-driven wrapper around the official unirate-api Python client, with optional Django-cache integration. - Drop-in DRF views (rates / convert / currencies) that keep the API key server-side, mountable via rest_framework_unirate.urls. - Serializer fields: CurrencyCodeField + ConvertedAmountField. - unirate_exception_handler mapping unirate errors to HTTP responses. - 43 mock tests (responses); CI matrix Python 3.10-3.13 x Django 4.2-5.2 x DRF 3.15-3.16; OIDC Trusted Publishing release workflow. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit 588917e

26 files changed

Lines changed: 2421 additions & 0 deletions

.github/workflows/release.yml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: release
2+
3+
on:
4+
push:
5+
tags: ["v*.*.*"]
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
outputs:
15+
version: ${{ steps.version.outputs.version }}
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v3
21+
22+
- name: Set up Python
23+
run: uv python install 3.12
24+
25+
- name: Verify tag matches package version
26+
id: version
27+
run: |
28+
PKG_VERSION=$(grep -E '^version = ' pyproject.toml | head -1 | sed -E 's/version = "(.*)"/\1/')
29+
TAG_VERSION=${GITHUB_REF#refs/tags/v}
30+
echo "package version: $PKG_VERSION"
31+
echo "tag version: $TAG_VERSION"
32+
if [ "${{ github.event_name }}" = "push" ] && [ "$PKG_VERSION" != "$TAG_VERSION" ]; then
33+
echo "Tag $TAG_VERSION does not match pyproject.toml version $PKG_VERSION" >&2
34+
exit 1
35+
fi
36+
echo "version=$PKG_VERSION" >> "$GITHUB_OUTPUT"
37+
38+
- name: Build sdist + wheel
39+
run: uv build
40+
41+
- name: Upload build artifacts
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: dist
45+
path: dist/
46+
47+
publish:
48+
needs: build
49+
runs-on: ubuntu-latest
50+
environment:
51+
name: pypi
52+
url: https://pypi.org/p/djangorestframework-unirate
53+
permissions:
54+
id-token: write # Required for PyPI Trusted Publisher (OIDC)
55+
steps:
56+
- name: Download build artifacts
57+
uses: actions/download-artifact@v4
58+
with:
59+
name: dist
60+
path: dist/
61+
62+
- name: Publish to PyPI
63+
uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/test.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
runs-on: ubuntu-latest
15+
strategy:
16+
fail-fast: false
17+
matrix:
18+
include:
19+
- python-version: "3.10"
20+
django: "4.2"
21+
drf: "3.15"
22+
- python-version: "3.11"
23+
django: "5.0"
24+
drf: "3.15"
25+
- python-version: "3.12"
26+
django: "5.1"
27+
drf: "3.16"
28+
- python-version: "3.13"
29+
django: "5.2"
30+
drf: "3.16"
31+
steps:
32+
- uses: actions/checkout@v4
33+
34+
- name: Install uv
35+
uses: astral-sh/setup-uv@v3
36+
37+
- name: Set up Python ${{ matrix.python-version }}
38+
run: uv python install ${{ matrix.python-version }}
39+
40+
- name: Install dependencies (Django ${{ matrix.django }} + DRF ${{ matrix.drf }})
41+
run: |
42+
uv sync --all-groups --python ${{ matrix.python-version }}
43+
uv pip install --python ${{ matrix.python-version }} \
44+
"Django~=${{ matrix.django }}.0" \
45+
"djangorestframework~=${{ matrix.drf }}.0"
46+
47+
- name: Lint
48+
run: |
49+
uv run --group lint ruff check rest_framework_unirate tests
50+
uv run --group lint ruff format rest_framework_unirate tests --diff
51+
52+
- name: Run unit tests
53+
run: uv run --group test pytest tests/ -v

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
__pycache__/
2+
*.py[cod]
3+
*.egg-info/
4+
.eggs/
5+
build/
6+
dist/
7+
.venv/
8+
venv/
9+
.mypy_cache/
10+
.pytest_cache/
11+
.ruff_cache/
12+
.coverage
13+
htmlcov/
14+
.DS_Store

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Changelog
2+
3+
All notable changes to this project are documented here. The format follows
4+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
5+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## [0.1.0] — 2026-06-12
8+
9+
### Added
10+
11+
- `UniRateAccessor` — a cached, Django-settings-driven wrapper around the
12+
official `unirate-api` Python client, with optional Django-cache
13+
integration (`UNIRATE_CACHE_TIMEOUT`).
14+
- Drop-in DRF API views (`ExchangeRateView`, `ConvertView`,
15+
`SupportedCurrenciesView`) mountable via `rest_framework_unirate.urls`,
16+
keeping the UniRate API key server-side.
17+
- Serializer fields: `CurrencyCodeField` (normalises + optionally validates
18+
ISO-4217 codes) and `ConvertedAmountField` (live currency conversion on a
19+
model's monetary amount).
20+
- `unirate_exception_handler` — maps `unirate` client errors onto sensible
21+
HTTP responses (404 / 400 / 429 for caller-input faults; 502 / 503 for
22+
upstream/gateway problems).
23+
- `responses`-backed mock test suite + CI matrix across Python 3.10–3.13,
24+
Django 4.2–5.2, and DRF 3.15–3.16.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Unirate Team
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.

README.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# djangorestframework-unirate
2+
3+
Django REST Framework integration for the [UniRate](https://unirateapi.com)
4+
currency-exchange API. Drop-in API views for live exchange rates, conversion,
5+
and supported-currency lookups — plus serializer fields and a cached,
6+
settings-driven client — all keeping your UniRate API key safely server-side.
7+
8+
- **Drop-in endpoints** — mount three ready-made views (`rates`, `convert`,
9+
`currencies`) under any URL prefix; clients call *your* API, never UniRate
10+
directly, so the key never reaches the browser.
11+
- **Serializer fields**`CurrencyCodeField` (normalises/validates ISO-4217
12+
codes) and `ConvertedAmountField` (live-converts a model's amount into a
13+
target currency).
14+
- **Thin & aligned** — wraps the official [`unirate-api`](https://pypi.org/project/unirate-api/)
15+
Python client, so behaviour and error semantics match every other UniRate
16+
library. Optional Django-cache integration. Zero extra runtime deps.
17+
18+
Companion to [`UniRateBackend` in django-money](https://github.com/django-money/django-money):
19+
django-money gives you a UniRate *exchange backend*; this package gives you a
20+
UniRate-powered *REST surface* on top of DRF.
21+
22+
## Install
23+
24+
```bash
25+
pip install djangorestframework-unirate
26+
```
27+
28+
Add it to `INSTALLED_APPS` and configure your key:
29+
30+
```python
31+
INSTALLED_APPS = [
32+
# ...
33+
"rest_framework",
34+
"rest_framework_unirate",
35+
]
36+
37+
UNIRATE_API_KEY = "your-api-key" # or set the UNIRATE_API_KEY env var
38+
```
39+
40+
Get a free API key at [unirateapi.com](https://unirateapi.com).
41+
42+
## Quick start — drop-in endpoints
43+
44+
```python
45+
# urls.py
46+
from django.urls import include, path
47+
48+
urlpatterns = [
49+
path("api/fx/", include("rest_framework_unirate.urls")),
50+
]
51+
```
52+
53+
That exposes:
54+
55+
| Endpoint | Example | Response |
56+
|---|---|---|
57+
| `GET api/fx/rates/?from=USD&to=EUR` | single pair | `{"from_currency": "USD", "to_currency": "EUR", "rate": 0.92}` |
58+
| `GET api/fx/rates/?from=USD` | all pairs | `{"base": "USD", "rates": {"EUR": 0.92, "GBP": 0.79, ...}}` |
59+
| `GET api/fx/convert/?from=USD&to=EUR&amount=100` | convert | `{"from_currency": "USD", "to_currency": "EUR", "amount": 100.0, "result": 92.0}` |
60+
| `GET api/fx/currencies/` | supported list | `{"currencies": ["USD", "EUR", "GBP", ...]}` |
61+
62+
`from` defaults to `UNIRATE_DEFAULT_BASE_CURRENCY` (USD); `amount` defaults to
63+
`1`. Currency codes are case-insensitive and normalised to upper-case.
64+
65+
## Serializer fields
66+
67+
### `ConvertedAmountField`
68+
69+
Adds a live-converted amount to any serializer, derived from a sibling
70+
currency field:
71+
72+
```python
73+
from rest_framework import serializers
74+
from rest_framework_unirate.fields import ConvertedAmountField
75+
76+
class ProductSerializer(serializers.Serializer):
77+
name = serializers.CharField()
78+
price = serializers.FloatField()
79+
currency = serializers.CharField()
80+
price_eur = ConvertedAmountField(
81+
amount_field="price",
82+
from_currency_field="currency", # or from_currency="USD"
83+
to_currency="EUR", # or omit + pass context={"target_currency": ...}
84+
)
85+
```
86+
87+
### `CurrencyCodeField`
88+
89+
A `CharField` that upper-cases input and enforces a 3-letter alphabetic code.
90+
Pass `validate_supported=True` to additionally check it against the live
91+
`/api/currencies` list (one cached call):
92+
93+
```python
94+
from rest_framework_unirate.fields import CurrencyCodeField
95+
96+
class QuoteSerializer(serializers.Serializer):
97+
base = CurrencyCodeField()
98+
quote = CurrencyCodeField(validate_supported=True)
99+
```
100+
101+
## Using the client directly
102+
103+
```python
104+
from rest_framework_unirate.client import get_accessor
105+
106+
accessor = get_accessor()
107+
accessor.get_rate("USD", "EUR") # 0.92
108+
accessor.get_rates("USD") # {"EUR": 0.92, "GBP": 0.79, ...}
109+
accessor.convert("USD", "EUR", 100) # 92.0
110+
accessor.get_supported_currencies() # ["USD", "EUR", ...]
111+
```
112+
113+
## Configuration
114+
115+
| Setting | Default | Purpose |
116+
|---|---|---|
117+
| `UNIRATE_API_KEY` | — (required) | Your UniRate API key |
118+
| `UNIRATE_TIMEOUT` | `30` | HTTP timeout in seconds |
119+
| `UNIRATE_BASE_URL` | `https://api.unirateapi.com` | Override the API base URL |
120+
| `UNIRATE_CACHE_TIMEOUT` | `None` | Seconds to cache rates/currencies in Django's cache (off when unset) |
121+
| `UNIRATE_CACHE_ALIAS` | `default` | Which `CACHES` alias to use |
122+
| `UNIRATE_DEFAULT_BASE_CURRENCY` | `USD` | Base currency when a request omits `from` |
123+
124+
## Error handling
125+
126+
The bundled views map `unirate` client errors onto sensible HTTP responses:
127+
128+
| Situation | Status |
129+
|---|---|
130+
| Unknown currency (upstream 404) | `404 Not Found` |
131+
| Bad parameters (upstream 400) | `400 Bad Request` |
132+
| Upstream rate limit (429) | `429 Too Many Requests` |
133+
| Bad server-side API key / Pro-gated 403 | `502 Bad Gateway` |
134+
| UniRate unavailable (503) / network failure | `503` / `502` |
135+
136+
Because the API key is server-side, an upstream `401` is treated as a gateway
137+
misconfiguration (`502`), not as a client auth failure. To apply the same
138+
mapping to your *own* DRF views, set the handler globally:
139+
140+
```python
141+
REST_FRAMEWORK = {
142+
"EXCEPTION_HANDLER": "rest_framework_unirate.exceptions.unirate_exception_handler",
143+
}
144+
```
145+
146+
## Rate limits
147+
148+
Latest rates, conversion, and the currency list are free-tier endpoints.
149+
Historical rates and time-series are Pro-gated and return `403` on the free
150+
tier. Set `UNIRATE_CACHE_TIMEOUT` to cut your upstream call volume.
151+
152+
## Compatibility
153+
154+
- Python 3.10–3.13
155+
- Django 4.2, 5.0, 5.1, 5.2
156+
- Django REST Framework 3.15–3.16
157+
158+
## Related UniRate clients
159+
160+
Official UniRate libraries: Python, Node/TypeScript, Go, Rust, Ruby, PHP,
161+
Java, Swift, .NET — plus framework integrations for FastAPI, Flask, Wagtail,
162+
LangChain, dbt, Airflow, and more. See the
163+
[UniRate-API org](https://github.com/UniRate-API).
164+
165+
## License
166+
167+
MIT — see [LICENSE](LICENSE).

0 commit comments

Comments
 (0)