Skip to content

Commit e7cf3ba

Browse files
rob-brownccclaude
andcommitted
Initial commit: @unirate/express v0.1.0
Express 4 + 5 middleware and router for the UniRate currency-exchange API. Attaches a typed unirate client to req.unirate, or mounts read-only proxy routes that keep the API key server-side. Zero runtime deps; express is a peer dependency. 52 vitest tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
0 parents  commit e7cf3ba

20 files changed

Lines changed: 5934 additions & 0 deletions

.github/workflows/release.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
release:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
id-token: write
14+
environment:
15+
name: npm
16+
steps:
17+
- uses: actions/checkout@v4
18+
- uses: actions/setup-node@v4
19+
with:
20+
node-version: "22"
21+
registry-url: "https://registry.npmjs.org"
22+
cache: npm
23+
- run: npm ci
24+
- run: npm test
25+
- run: npm run typecheck
26+
- run: npm publish --provenance --access public
27+
env:
28+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
29+
- name: GitHub Release
30+
uses: softprops/action-gh-release@v2
31+
with:
32+
generate_release_notes: true

.github/workflows/test.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: Test
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
node-version: ["20", "22"]
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-node@v4
18+
with:
19+
node-version: ${{ matrix.node-version }}
20+
cache: npm
21+
- run: npm ci
22+
- run: npm test
23+
- run: npm run typecheck
24+
- run: npm run build

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
node_modules
2+
dist
3+
*.tsbuildinfo
4+
.DS_Store

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: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# @unirate/express
2+
3+
[Express](https://expressjs.com) middleware and router for the [UniRate API](https://unirateapi.com) — free currency exchange rates, conversion, and VAT rates.
4+
5+
Attach a typed UniRate client to every request, or mount a ready-made set of JSON endpoints, and keep your API key server-side. Works with Express 4 and 5.
6+
7+
## Features
8+
9+
- `unirateClient()` middleware — attaches a typed `UniRateClient` to `req.unirate`
10+
- `unirate()` — a mountable Express `Router` with `/rate`, `/convert`, `/currencies`, `/vat` routes
11+
- Full error mapping — UniRate statuses (400 / 401 / 403 / 404 / 429 / 503) mirrored to proper HTTP responses; transport failures become 502; a missing key becomes 500
12+
- Currency-code validation + uppercasing
13+
- API key read from options or the `UNIRATE_API_KEY` environment variable
14+
- Zero runtime dependencies (uses native `fetch`); `express` is a peer dependency
15+
- TypeScript throughout — `req.unirate` is typed via Express module augmentation
16+
- ESM + CJS builds
17+
18+
## Install
19+
20+
```bash
21+
npm install @unirate/express express
22+
```
23+
24+
`express` is a peer dependency (`>=4.0.0`, works with v4 and v5). Requires Node 18+ for native `fetch`. Get a free UniRate API key at [unirateapi.com](https://unirateapi.com).
25+
26+
## Quick start
27+
28+
### Middleware
29+
30+
```ts
31+
import express from "express";
32+
import { unirateClient } from "@unirate/express";
33+
34+
const app = express();
35+
app.use(unirateClient()); // reads process.env.UNIRATE_API_KEY
36+
37+
app.get("/eur", async (req, res) => {
38+
const rate = await req.unirate.getRate("USD", "EUR");
39+
res.json({ pair: "USD/EUR", rate });
40+
});
41+
42+
app.listen(3000);
43+
```
44+
45+
`req.unirate` is fully typed via Express's `Request` interface — no casting needed.
46+
47+
### Router
48+
49+
```ts
50+
import express from "express";
51+
import { unirate } from "@unirate/express";
52+
53+
const app = express();
54+
app.use("/api/unirate", unirate());
55+
```
56+
57+
That mounts:
58+
59+
```
60+
GET /api/unirate/rate?from=USD&to=EUR
61+
GET /api/unirate/convert?from=USD&to=EUR&amount=100
62+
GET /api/unirate/currencies
63+
GET /api/unirate/vat?country=DE
64+
```
65+
66+
## Options
67+
68+
Both `unirateClient()` and `unirate()` accept the same options:
69+
70+
| Option | Type | Default | Description |
71+
|---|---|---|---|
72+
| `apiKey` | `string` || API key. If omitted, resolved from the env variable |
73+
| `envKey` | `string` | `"UNIRATE_API_KEY"` | Name of the env variable to read the key from |
74+
| `baseUrl` | `string` | `https://api.unirateapi.com` | Override the API base URL |
75+
| `fetch` | `typeof fetch` | `globalThis.fetch` | Inject a custom `fetch` |
76+
| `timeoutMs` | `number` | `30000` | Request timeout |
77+
| `userAgent` | `string` | `@unirate/express/<version>` | Custom User-Agent header |
78+
79+
```ts
80+
app.use(unirateClient({ apiKey: process.env.UNIRATE_API_KEY }));
81+
// or a custom env variable name
82+
app.use("/fx", unirate({ envKey: "MY_UNIRATE_KEY" }));
83+
```
84+
85+
## Routes
86+
87+
### `GET /rate`
88+
89+
| Param | Required | Default | Description |
90+
|---|---|---|---|
91+
| `from` | no | `USD` | Source currency (3-letter ISO code) |
92+
| `to` | no || Target currency. Omit to return all rates for `from` |
93+
94+
```jsonc
95+
// /rate?from=USD&to=EUR →
96+
{ "rate": 0.92 }
97+
// /rate?from=USD →
98+
{ "rates": { "EUR": 0.92, "GBP": 0.79 } }
99+
```
100+
101+
### `GET /convert`
102+
103+
| Param | Required | Default | Description |
104+
|---|---|---|---|
105+
| `from` | no | `USD` | Source currency |
106+
| `to` | yes || Target currency |
107+
| `amount` | no | `1` | Amount to convert (positive number) |
108+
109+
```jsonc
110+
// /convert?from=USD&to=EUR&amount=100 →
111+
{ "result": 92.5 }
112+
```
113+
114+
### `GET /currencies`
115+
116+
```jsonc
117+
{ "currencies": ["USD", "EUR", "GBP", "..."] }
118+
```
119+
120+
### `GET /vat`
121+
122+
| Param | Required | Default | Description |
123+
|---|---|---|---|
124+
| `country` | no || ISO-3166 alpha-2 code. Omit for all countries |
125+
126+
```jsonc
127+
// /vat?country=DE →
128+
{ "country": "DE", "vat_data": { "country_code": "DE", "country_name": "Germany", "vat_rate": 19 } }
129+
```
130+
131+
## Using the client directly
132+
133+
The internal client is exported for direct use in your own handlers:
134+
135+
```ts
136+
import { UniRateClient, RateLimitError } from "@unirate/express";
137+
138+
const client = new UniRateClient({ apiKey: "..." });
139+
140+
try {
141+
const rate = await client.getRate("USD", "EUR"); // number
142+
const all = await client.getRate("USD"); // Record<string, number>
143+
const eur = await client.convert("EUR", 100, "USD"); // number
144+
const codes = await client.getSupportedCurrencies(); // string[]
145+
const vat = await client.getVatRate("DE"); // { country, vat_data }
146+
} catch (err) {
147+
if (err instanceof RateLimitError) {
148+
// back off and retry
149+
}
150+
}
151+
```
152+
153+
It is also available on the `@unirate/express/client` subpath if you want the client without pulling in the Express glue.
154+
155+
## Error handling
156+
157+
The router maps thrown errors to HTTP responses with a `{ "error": string }` body:
158+
159+
| Status | Meaning |
160+
|---|---|
161+
| `400` | Invalid parameter (bad currency/country code, missing/invalid amount) |
162+
| `401` | Missing or invalid API key |
163+
| `403` | Endpoint requires a Pro subscription |
164+
| `404` | Currency not found or no data available |
165+
| `429` | Rate limit exceeded |
166+
| `500` | API key not configured on the server |
167+
| `502` | UniRate upstream / transport error |
168+
| `503` | Service unavailable |
169+
170+
When using the client directly, these map to typed error classes — all extending `UniRateError`:
171+
`InvalidRequestError`, `AuthenticationError`, `ProRequiredError`, `InvalidCurrencyError`, `RateLimitError`, `ServiceUnavailableError`.
172+
173+
## Free vs Pro tier
174+
175+
The free tier covers `/rate`, `/convert`, `/currencies`, and `/vat`. Historical and time-series endpoints require a [Pro subscription](https://unirateapi.com/pricing) and are not exposed by this package.
176+
177+
## Example
178+
179+
A runnable Express app lives in [`examples/server.mjs`](./examples/server.mjs):
180+
181+
```bash
182+
npm run build
183+
UNIRATE_API_KEY=your-key node examples/server.mjs
184+
```
185+
186+
## Related packages
187+
188+
**UniRate API client libraries:** [Python](https://github.com/UniRate-API/unirate-api-python) · [Node.js](https://github.com/UniRate-API/unirate-api-nodejs) · [Go](https://github.com/UniRate-API/unirate-api-go) · [Rust](https://github.com/UniRate-API/unirate-api-rust) · [Ruby](https://github.com/UniRate-API/unirate-api-ruby) · [PHP](https://github.com/UniRate-API/unirate-api-php) · [Java](https://github.com/UniRate-API/unirate-api-java) · [Swift](https://github.com/UniRate-API/unirate-api-swift) · [.NET](https://github.com/UniRate-API/unirate-api-dotnet)
189+
190+
**Framework integrations:** [Fastify](https://github.com/UniRate-API/fastify-unirate) · [Hono](https://github.com/UniRate-API/hono-unirate) · [Next.js](https://github.com/UniRate-API/next-unirate) · [Nuxt](https://github.com/UniRate-API/nuxt-unirate) · [SvelteKit](https://github.com/UniRate-API/sveltekit-unirate) · [Astro](https://github.com/UniRate-API/astro-unirate) · [NestJS](https://github.com/UniRate-API/nestjs-unirate) · [Remix](https://github.com/UniRate-API/remix-unirate) · [Angular](https://github.com/UniRate-API/angular-unirate) · [Vue](https://github.com/UniRate-API/vue-unirate) · [React](https://github.com/UniRate-API/react-unirate)
191+
192+
**Platform:** [Cloudflare Workers](https://github.com/UniRate-API/cloudflare-workers-unirate) · [MCP server](https://github.com/UniRate-API/unirate-mcp) · [CLI](https://github.com/UniRate-API/unirate-cli)
193+
194+
## License
195+
196+
MIT © Unirate Team

examples/server.mjs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Runnable example. Requires a built dist/ (npm run build) and a key:
2+
//
3+
// UNIRATE_API_KEY=your-key node examples/server.mjs
4+
//
5+
// Then:
6+
// curl 'http://localhost:3000/api/unirate/rate?from=USD&to=EUR'
7+
// curl 'http://localhost:3000/api/unirate/convert?from=USD&to=EUR&amount=100'
8+
// curl 'http://localhost:3000/api/unirate/currencies'
9+
// curl 'http://localhost:3000/api/unirate/vat?country=DE'
10+
// curl 'http://localhost:3000/price' # uses the attached req.unirate client
11+
12+
import express from "express";
13+
import { unirate, unirateClient } from "../dist/index.js";
14+
15+
const apiKey = process.env.UNIRATE_API_KEY;
16+
if (!apiKey) {
17+
console.error("Set UNIRATE_API_KEY in your environment first.");
18+
process.exit(1);
19+
}
20+
21+
const app = express();
22+
23+
// 1) Mount the ready-made JSON proxy routes. The API key stays server-side.
24+
app.use("/api/unirate", unirate({ apiKey }));
25+
26+
// 2) Attach a typed client to every request as `req.unirate`, then use it
27+
// directly in your own handlers.
28+
app.use(unirateClient({ apiKey }));
29+
30+
app.get("/price", async (req, res, next) => {
31+
try {
32+
const rate = await req.unirate.getRate("USD", "EUR");
33+
const converted = await req.unirate.convert("EUR", 100, "USD");
34+
const currencies = await req.unirate.getSupportedCurrencies();
35+
res.json({
36+
usd_to_eur: rate,
37+
"100_usd_in_eur": converted,
38+
supported_count: currencies.length,
39+
});
40+
} catch (err) {
41+
next(err);
42+
}
43+
});
44+
45+
app.listen(3000, () => {
46+
console.log("Listening on http://localhost:3000");
47+
});

0 commit comments

Comments
 (0)