sinatra-unirate 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/LICENSE +21 -0
- data/README.md +163 -0
- data/lib/sinatra/unirate/client.rb +173 -0
- data/lib/sinatra/unirate/errors.rb +31 -0
- data/lib/sinatra/unirate/version.rb +11 -0
- data/lib/sinatra/unirate.rb +157 -0
- data/lib/sinatra-unirate.rb +5 -0
- data/sinatra-unirate.gemspec +53 -0
- metadata +148 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dd7e1bb21ba1426d6905e2cc791210fcd6b455eb16f76b20383038f1a926443d
|
|
4
|
+
data.tar.gz: 429c7bc86582eb904d8774b3c9d09c8506615226abfd648b7ea1dcc691bf2115
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ad5415d873ceb4fd631125537e62c41f9050ecc9e30aec0482d54c1f008520dd813b5d15e41f73197961762700b29b4b46271e65b2985b384592874409b314f1
|
|
7
|
+
data.tar.gz: 26b6007155df60b37beb909da5bdc94ad2a4b413a06be9e0d0a85dd0cd6a06c7d072b035ee94b89bdb3ae85448580f23e1f1f1f786ab8831ba31c4142c6ce827
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format is based
|
|
4
|
+
on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
|
5
|
+
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-07-20
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Initial release.
|
|
12
|
+
- `Sinatra::UniRate` — a Sinatra extension registered via
|
|
13
|
+
`register Sinatra::UniRate` in classic or modular apps.
|
|
14
|
+
- Settings-based configuration: `:unirate_api_key` (with an
|
|
15
|
+
`UNIRATE_API_KEY` env-var fallback), `:unirate_base_url`,
|
|
16
|
+
`:unirate_timeout`, `:unirate_default_currency`,
|
|
17
|
+
`:unirate_enable_historical`, and `:unirate_mount_routes`.
|
|
18
|
+
- Route/view helpers `unirate_rate`, `unirate_convert`, `unirate_currencies`,
|
|
19
|
+
and `unirate_vat`, plus `unirate_client`.
|
|
20
|
+
- Optional mountable JSON proxy routes (`GET /unirate/rate`,
|
|
21
|
+
`GET /unirate/convert`, `GET /unirate/currencies`) enabled by
|
|
22
|
+
`set :unirate_mount_routes, true`.
|
|
23
|
+
- `UniRate::SinatraClient` — stdlib-only (`net/http` + `json`) client exposing
|
|
24
|
+
`get_rate`, `convert`, `get_supported_currencies`, `get_vat_rates`, and the
|
|
25
|
+
Pro-gated `get_historical_rate`, with full HTTP error mapping.
|
|
26
|
+
- Historical endpoint feature-flagged off by default (Pro-gated / 403 on the
|
|
27
|
+
free tier).
|
data/LICENSE
ADDED
|
@@ -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.
|
data/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# sinatra-unirate
|
|
2
|
+
|
|
3
|
+
A [Sinatra](https://sinatrarb.com) extension for the
|
|
4
|
+
[UniRate API](https://unirateapi.com) — free, real-time currency exchange
|
|
5
|
+
rates, conversion, supported-currency listings, and VAT rates, wired straight
|
|
6
|
+
into your Sinatra app.
|
|
7
|
+
|
|
8
|
+
- `register Sinatra::UniRate` in classic **or** modular apps
|
|
9
|
+
- Settings-based configuration (`set :unirate_api_key, ...`), or read the key
|
|
10
|
+
from `UNIRATE_API_KEY` automatically
|
|
11
|
+
- Route/view helpers — `unirate_rate`, `unirate_convert`, `unirate_currencies`,
|
|
12
|
+
`unirate_vat` — available in every route and view
|
|
13
|
+
- Optional mountable JSON proxy routes so your frontend never sees the API key
|
|
14
|
+
- `unirate_client` — a plain client for service objects
|
|
15
|
+
- 170+ currencies (fiat + crypto) via UniRate
|
|
16
|
+
- Free tier, no credit card required
|
|
17
|
+
- **Zero runtime dependencies beyond Sinatra** (pure stdlib `net/http` + `json`)
|
|
18
|
+
|
|
19
|
+
> **Affiliation:** this extension is maintained by the UniRate team and talks to
|
|
20
|
+
> the UniRate API. If you only need euro-area rates the ECB feed may suit you
|
|
21
|
+
> better; for a broad multi-currency source on a free tier, UniRate is a good
|
|
22
|
+
> fit.
|
|
23
|
+
|
|
24
|
+
## Requirements
|
|
25
|
+
|
|
26
|
+
- Ruby 3.0+
|
|
27
|
+
- Sinatra 2.0+
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
# Gemfile
|
|
33
|
+
gem "sinatra-unirate"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bundle install
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
### Classic app
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require "sinatra"
|
|
46
|
+
require "sinatra/unirate"
|
|
47
|
+
|
|
48
|
+
set :unirate_api_key, ENV.fetch("UNIRATE_API_KEY") # or leave unset to read the env var lazily
|
|
49
|
+
|
|
50
|
+
get "/eur" do
|
|
51
|
+
"100 USD = #{unirate_convert(100, "USD", "EUR")} EUR"
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Modular app
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
require "sinatra/base"
|
|
59
|
+
require "sinatra/unirate"
|
|
60
|
+
|
|
61
|
+
class MyApp < Sinatra::Base
|
|
62
|
+
register Sinatra::UniRate
|
|
63
|
+
set :unirate_api_key, ENV.fetch("UNIRATE_API_KEY")
|
|
64
|
+
|
|
65
|
+
get "/eur" do
|
|
66
|
+
"100 USD = #{unirate_convert(100, "USD", "EUR")} EUR"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Get a free API key at [unirateapi.com](https://unirateapi.com).
|
|
72
|
+
|
|
73
|
+
## Helpers
|
|
74
|
+
|
|
75
|
+
Available in every route and view once the extension is registered:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
unirate_rate("USD", "EUR") # => 0.92
|
|
79
|
+
unirate_rate("USD") # => { "EUR" => 0.92, "GBP" => 0.79, ... }
|
|
80
|
+
unirate_convert(100, "USD", "EUR") # => 92.5
|
|
81
|
+
unirate_currencies # => ["USD", "EUR", "GBP", ...]
|
|
82
|
+
unirate_vat("DE") # => { "country_code" => "DE", "vat_rate" => 19.0 }
|
|
83
|
+
unirate_client # => UniRate::SinatraClient for advanced use
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`unirate_rate` and `unirate_convert` default the base currency to
|
|
87
|
+
`:unirate_default_currency` (`"USD"`) when the `from` argument is omitted.
|
|
88
|
+
|
|
89
|
+
## JSON proxy routes
|
|
90
|
+
|
|
91
|
+
Set `:unirate_mount_routes` to add server-side proxy endpoints — the API key
|
|
92
|
+
stays on the server:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
set :unirate_mount_routes, true
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
GET /unirate/rate?from=USD&to=EUR => { "rate": 0.92 }
|
|
100
|
+
GET /unirate/convert?from=USD&to=EUR&amount=100 => { "result": 92.5 }
|
|
101
|
+
GET /unirate/currencies => { "currencies": ["USD", ...] }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Typed UniRate errors are mapped back to their HTTP status.
|
|
105
|
+
|
|
106
|
+
## Configuration
|
|
107
|
+
|
|
108
|
+
All configuration is expressed through Sinatra settings:
|
|
109
|
+
|
|
110
|
+
| Setting | Default | Description |
|
|
111
|
+
|------------------------------|------------------------------|------------------------------------------------------|
|
|
112
|
+
| `:unirate_api_key` | `ENV["UNIRATE_API_KEY"]` | Your UniRate API key. |
|
|
113
|
+
| `:unirate_base_url` | `https://api.unirateapi.com` | API base URL. |
|
|
114
|
+
| `:unirate_timeout` | `30` | HTTP open/read timeout in seconds. |
|
|
115
|
+
| `:unirate_default_currency` | `"USD"` | Base used by the one/two-arg helpers. |
|
|
116
|
+
| `:unirate_enable_historical` | `false` | Enable the Pro-gated historical endpoint (see below).|
|
|
117
|
+
| `:unirate_mount_routes` | `false` | Add the `/unirate/*` JSON proxy routes. |
|
|
118
|
+
|
|
119
|
+
## Error handling
|
|
120
|
+
|
|
121
|
+
Every failure raises a subclass of `UniRate::UnirateError`:
|
|
122
|
+
|
|
123
|
+
| HTTP | Exception |
|
|
124
|
+
|---------|--------------------------------------|
|
|
125
|
+
| 400 | `UniRate::InvalidDateError` |
|
|
126
|
+
| 401 | `UniRate::AuthenticationError` |
|
|
127
|
+
| 403 | `UniRate::APIError` (status 403) |
|
|
128
|
+
| 404 | `UniRate::InvalidCurrencyError` |
|
|
129
|
+
| 429 | `UniRate::RateLimitError` |
|
|
130
|
+
| 503 | `UniRate::APIError` (status 503) |
|
|
131
|
+
| other | `UniRate::APIError` |
|
|
132
|
+
| network | `UniRate::UnirateError` (base) |
|
|
133
|
+
|
|
134
|
+
```ruby
|
|
135
|
+
begin
|
|
136
|
+
unirate_rate("USD", "EUR")
|
|
137
|
+
rescue UniRate::RateLimitError
|
|
138
|
+
# back off and retry
|
|
139
|
+
rescue UniRate::UnirateError => e
|
|
140
|
+
logger.warn("UniRate: #{e.message}")
|
|
141
|
+
end
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Historical / VAT (Pro)
|
|
145
|
+
|
|
146
|
+
Historical rates are **Pro-gated** and return HTTP 403 on the free tier. They
|
|
147
|
+
are disabled by default; set `:unirate_enable_historical` to `true` (and hold a
|
|
148
|
+
Pro subscription) to call `get_historical_rate` on the client.
|
|
149
|
+
|
|
150
|
+
## Rate limits
|
|
151
|
+
|
|
152
|
+
The free tier is rate limited; a 429 raises `UniRate::RateLimitError`. Cache
|
|
153
|
+
responses in your app if you make frequent calls.
|
|
154
|
+
|
|
155
|
+
## Related clients
|
|
156
|
+
|
|
157
|
+
Part of the UniRate client family — see
|
|
158
|
+
[github.com/UniRate-API](https://github.com/UniRate-API) for Python, Node,
|
|
159
|
+
Swift, Java, Go, Rust, Ruby, PHP, and .NET clients plus framework integrations.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
require_relative "version"
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
|
|
10
|
+
module UniRate
|
|
11
|
+
# Stdlib-only HTTP client for the UniRate API (https://unirateapi.com).
|
|
12
|
+
#
|
|
13
|
+
# Zero runtime dependencies beyond Ruby's standard library (`net/http`,
|
|
14
|
+
# `json`, `uri`) — no third-party HTTP gem, so nothing extra to audit in a
|
|
15
|
+
# Sinatra app's dependency tree.
|
|
16
|
+
#
|
|
17
|
+
# Method names and parameter order mirror the canonical UniRate client spec.
|
|
18
|
+
# Currency and country codes are upcased before being sent.
|
|
19
|
+
class SinatraClient
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.unirateapi.com"
|
|
21
|
+
DEFAULT_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
def initialize(api_key:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT, enable_historical: false)
|
|
24
|
+
@api_key = api_key
|
|
25
|
+
@base_url = base_url || DEFAULT_BASE_URL
|
|
26
|
+
@timeout = timeout || DEFAULT_TIMEOUT
|
|
27
|
+
@enable_historical = enable_historical
|
|
28
|
+
|
|
29
|
+
raise AuthenticationError.new("Missing or invalid API key", status: 401) if @api_key.nil? || @api_key.to_s.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# GET /api/rates — current rate(s).
|
|
33
|
+
# Returns a Float when +to+ is given, otherwise a { "EUR" => Float, ... } map.
|
|
34
|
+
def get_rate(from: "USD", to: nil, format: "json", callback: nil)
|
|
35
|
+
params = { "from" => up(from) }
|
|
36
|
+
params["to"] = up(to) if to
|
|
37
|
+
body = request("/api/rates", params, format: format, callback: callback)
|
|
38
|
+
return body unless json?(format)
|
|
39
|
+
|
|
40
|
+
if to
|
|
41
|
+
to_f(body.fetch("rate"))
|
|
42
|
+
else
|
|
43
|
+
floatify(body.fetch("rates", {}))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# GET /api/convert — current conversion. Returns a Float.
|
|
48
|
+
def convert(to:, amount: 1, from: "USD", format: "json", callback: nil)
|
|
49
|
+
params = { "from" => up(from), "to" => up(to), "amount" => amount }
|
|
50
|
+
body = request("/api/convert", params, format: format, callback: callback)
|
|
51
|
+
return body unless json?(format)
|
|
52
|
+
|
|
53
|
+
to_f(body.fetch("result"))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# GET /api/currencies — supported currency codes. Returns an Array of String.
|
|
57
|
+
def get_supported_currencies(format: "json", callback: nil)
|
|
58
|
+
body = request("/api/currencies", {}, format: format, callback: callback)
|
|
59
|
+
return body unless json?(format)
|
|
60
|
+
|
|
61
|
+
Array(body.fetch("currencies", []))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# GET /api/vat/rates — VAT rates. Returns the whole map keyed by country when
|
|
65
|
+
# +country+ is nil, otherwise the single-country vat_data hash.
|
|
66
|
+
def get_vat_rates(country: nil, format: "json", callback: nil)
|
|
67
|
+
params = {}
|
|
68
|
+
params["country"] = up(country) if country
|
|
69
|
+
body = request("/api/vat/rates", params, format: format, callback: callback)
|
|
70
|
+
return body unless json?(format)
|
|
71
|
+
|
|
72
|
+
country ? body.fetch("vat_data") : body.fetch("vat_rates")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /api/historical/rates — Pro-gated (403 on free tier). Guarded by the
|
|
76
|
+
# `enable_historical` flag so a free-tier app doesn't hit it by accident.
|
|
77
|
+
# Returns a Float or a { code => Float } map depending on args.
|
|
78
|
+
def get_historical_rate(date:, amount: 1, from: "USD", to: nil, format: "json", callback: nil)
|
|
79
|
+
ensure_historical_enabled!
|
|
80
|
+
params = { "date" => date, "from" => up(from), "amount" => amount }
|
|
81
|
+
params["to"] = up(to) if to
|
|
82
|
+
body = request("/api/historical/rates", params, format: format, callback: callback)
|
|
83
|
+
return body unless json?(format)
|
|
84
|
+
|
|
85
|
+
parse_historical(body, to: to)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def ensure_historical_enabled!
|
|
91
|
+
return if @enable_historical
|
|
92
|
+
|
|
93
|
+
raise APIError.new(
|
|
94
|
+
"Historical endpoints are Pro-gated and disabled; set :unirate_enable_historical to true to use them",
|
|
95
|
+
status: 403
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def parse_historical(body, to:)
|
|
100
|
+
if to
|
|
101
|
+
body.key?("result") ? to_f(body["result"]) : to_f(body.fetch("rate"))
|
|
102
|
+
else
|
|
103
|
+
floatify(body.fetch(body.key?("results") ? "results" : "rates", {}))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def request(path, params, format:, callback:)
|
|
108
|
+
uri = build_uri(path, params, format: format, callback: callback)
|
|
109
|
+
req = Net::HTTP::Get.new(uri)
|
|
110
|
+
req["Accept"] = "application/json"
|
|
111
|
+
req["User-Agent"] = "unirate-sinatra/#{SinatraUniRate::VERSION}"
|
|
112
|
+
handle(perform(uri, req), format: format)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def build_uri(path, params, format:, callback:)
|
|
116
|
+
uri = URI.parse(@base_url)
|
|
117
|
+
uri.path = path
|
|
118
|
+
query = params.merge("api_key" => @api_key)
|
|
119
|
+
query["format"] = format if format && format != "json"
|
|
120
|
+
query["callback"] = callback if callback
|
|
121
|
+
uri.query = URI.encode_www_form(query)
|
|
122
|
+
uri
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def perform(uri, req)
|
|
126
|
+
Net::HTTP.start(uri.hostname, uri.port,
|
|
127
|
+
use_ssl: uri.scheme == "https",
|
|
128
|
+
open_timeout: @timeout,
|
|
129
|
+
read_timeout: @timeout) { |http| http.request(req) }
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
raise UnirateError, "Network error talking to UniRate: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def handle(response, format:)
|
|
135
|
+
status = response.code.to_i
|
|
136
|
+
raise_for_status(status, response.body) unless (200..299).cover?(status)
|
|
137
|
+
|
|
138
|
+
return response.body.to_s unless json?(format)
|
|
139
|
+
|
|
140
|
+
JSON.parse(response.body.to_s)
|
|
141
|
+
rescue JSON::ParserError => e
|
|
142
|
+
raise UnirateError, "Failed to parse UniRate response: #{e.message}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def raise_for_status(status, body)
|
|
146
|
+
case status
|
|
147
|
+
when 400 then raise InvalidDateError.new("Invalid request parameters", status: 400)
|
|
148
|
+
when 401 then raise AuthenticationError.new("Missing or invalid API key", status: 401)
|
|
149
|
+
when 403 then raise APIError.new("Endpoint requires a Pro subscription", status: 403)
|
|
150
|
+
when 404 then raise InvalidCurrencyError.new("Currency not found or no data available", status: 404)
|
|
151
|
+
when 429 then raise RateLimitError.new("Rate limit exceeded", status: 429)
|
|
152
|
+
when 503 then raise APIError.new("Service unavailable", status: 503)
|
|
153
|
+
else raise APIError.new("UniRate API error (HTTP #{status}): #{body}", status: status)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def json?(format)
|
|
158
|
+
format.nil? || format == "json"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def up(value)
|
|
162
|
+
value.to_s.upcase
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def to_f(value)
|
|
166
|
+
Float(value)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def floatify(hash)
|
|
170
|
+
hash.each_with_object({}) { |(code, value), out| out[code.to_s.upcase] = to_f(value) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UniRate
|
|
4
|
+
# Base class for every UniRate-specific failure. Network/transport problems
|
|
5
|
+
# are wrapped in this; the typed subclasses below carry the HTTP semantics.
|
|
6
|
+
class UnirateError < StandardError
|
|
7
|
+
# HTTP status code that produced the error, when one is available.
|
|
8
|
+
attr_reader :status
|
|
9
|
+
|
|
10
|
+
def initialize(message = nil, status: nil)
|
|
11
|
+
@status = status
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# 400 — malformed request parameters (e.g. a bad historical date).
|
|
17
|
+
class InvalidDateError < UnirateError; end
|
|
18
|
+
|
|
19
|
+
# 401 — missing or invalid API key.
|
|
20
|
+
class AuthenticationError < UnirateError; end
|
|
21
|
+
|
|
22
|
+
# 404 — currency not found or no data available.
|
|
23
|
+
class InvalidCurrencyError < UnirateError; end
|
|
24
|
+
|
|
25
|
+
# 429 — rate limit exceeded.
|
|
26
|
+
class RateLimitError < UnirateError; end
|
|
27
|
+
|
|
28
|
+
# 403 (Pro-gated endpoint on a free tier), 503, or any other non-2xx that
|
|
29
|
+
# doesn't map to a more specific type. Carries the HTTP status.
|
|
30
|
+
class APIError < UnirateError; end
|
|
31
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Standalone top-level module holding only the version constant. Kept
|
|
4
|
+
# deliberately free of any Sinatra-class reopening: the gemspec
|
|
5
|
+
# `require_relative`s this file at build time, *before* the runtime
|
|
6
|
+
# dependencies (sinatra) are installed. Opening a module that later collides
|
|
7
|
+
# with a Sinatra-defined class/module would risk a "superclass mismatch"
|
|
8
|
+
# TypeError during `gem build`, so the version lives on its own here.
|
|
9
|
+
module SinatraUniRate
|
|
10
|
+
VERSION = "0.1.0"
|
|
11
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sinatra/base"
|
|
4
|
+
|
|
5
|
+
require_relative "unirate/version"
|
|
6
|
+
require_relative "unirate/errors"
|
|
7
|
+
require_relative "unirate/client"
|
|
8
|
+
|
|
9
|
+
# Reopened only to define the UniRate extension below; Sinatra itself is
|
|
10
|
+
# provided by the `sinatra` gem.
|
|
11
|
+
module Sinatra
|
|
12
|
+
# Sinatra extension wrapping the UniRate API (https://unirateapi.com) for free
|
|
13
|
+
# currency exchange rates, conversion, supported-currency listings, and VAT
|
|
14
|
+
# rates.
|
|
15
|
+
#
|
|
16
|
+
# Register it in a classic app:
|
|
17
|
+
#
|
|
18
|
+
# require "sinatra"
|
|
19
|
+
# require "sinatra/unirate"
|
|
20
|
+
# set :unirate_api_key, ENV.fetch("UNIRATE_API_KEY")
|
|
21
|
+
#
|
|
22
|
+
# get("/eur") { unirate_convert(100, "USD", "EUR").to_s }
|
|
23
|
+
#
|
|
24
|
+
# ...or a modular app:
|
|
25
|
+
#
|
|
26
|
+
# require "sinatra/base"
|
|
27
|
+
# require "sinatra/unirate"
|
|
28
|
+
#
|
|
29
|
+
# class MyApp < Sinatra::Base
|
|
30
|
+
# register Sinatra::UniRate
|
|
31
|
+
# set :unirate_api_key, ENV.fetch("UNIRATE_API_KEY")
|
|
32
|
+
# get("/eur") { unirate_convert(100, "USD", "EUR").to_s }
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# Configuration is expressed through Sinatra settings:
|
|
36
|
+
#
|
|
37
|
+
# set :unirate_api_key, "..." # falls back to ENV["UNIRATE_API_KEY"]
|
|
38
|
+
# set :unirate_base_url, "https://api.unirateapi.com"
|
|
39
|
+
# set :unirate_timeout, 30
|
|
40
|
+
# set :unirate_default_currency, "USD"
|
|
41
|
+
# set :unirate_enable_historical, false # Pro-gated; off by default
|
|
42
|
+
# set :unirate_mount_routes, false # add /unirate/* JSON proxy routes
|
|
43
|
+
#
|
|
44
|
+
module UniRate
|
|
45
|
+
# Helpers mixed into every route/view when the extension is registered. Each
|
|
46
|
+
# helper builds a short-lived {SinatraClient} from the app's settings.
|
|
47
|
+
module Helpers
|
|
48
|
+
# A {SinatraClient} built from the current app settings. A fresh instance
|
|
49
|
+
# is returned each call so `set :unirate_*` changes are always picked up.
|
|
50
|
+
def unirate_client
|
|
51
|
+
Sinatra::UniRate.client_for(settings)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# GET /api/rates. Returns a Float when +to+ is given, else a code=>Float
|
|
55
|
+
# map. +from+ defaults to the app's :unirate_default_currency setting.
|
|
56
|
+
def unirate_rate(from = nil, to = nil)
|
|
57
|
+
from ||= settings.unirate_default_currency
|
|
58
|
+
unirate_client.get_rate(from: from, to: to)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Convert +amount+ from +from+ to +to+ (a Float). +from+ defaults to the
|
|
62
|
+
# app's :unirate_default_currency setting.
|
|
63
|
+
def unirate_convert(amount, from = nil, to = nil)
|
|
64
|
+
from ||= settings.unirate_default_currency
|
|
65
|
+
unirate_client.convert(to: to, amount: amount, from: from)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# GET /api/currencies. Returns an Array of currency-code strings.
|
|
69
|
+
def unirate_currencies
|
|
70
|
+
unirate_client.get_supported_currencies
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# GET /api/vat/rates. Returns the whole country map when +country+ is nil,
|
|
74
|
+
# otherwise the single-country vat_data hash.
|
|
75
|
+
def unirate_vat(country = nil)
|
|
76
|
+
unirate_client.get_vat_rates(country: country)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Shared wrapper for the mounted JSON proxy routes. Falls through to a 404
|
|
80
|
+
# unless :unirate_mount_routes is enabled, renders JSON on success, and
|
|
81
|
+
# maps a typed UniRate error to its HTTP status. Not intended for direct
|
|
82
|
+
# use in host-app routes.
|
|
83
|
+
def unirate_proxy
|
|
84
|
+
pass unless settings.unirate_mount_routes
|
|
85
|
+
content_type :json
|
|
86
|
+
yield.to_json
|
|
87
|
+
rescue ::UniRate::UnirateError => e
|
|
88
|
+
halt(e.status || 502, { error: e.message }.to_json)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class << self
|
|
93
|
+
# Builds a {SinatraClient} from a Sinatra settings object. The API key
|
|
94
|
+
# falls back to ENV["UNIRATE_API_KEY"] when the setting is unset/blank so a
|
|
95
|
+
# secret never has to be committed to the host app.
|
|
96
|
+
def client_for(settings)
|
|
97
|
+
key = settings.unirate_api_key
|
|
98
|
+
key = ENV.fetch("UNIRATE_API_KEY", nil) if key.nil? || key.to_s.empty?
|
|
99
|
+
|
|
100
|
+
::UniRate::SinatraClient.new(
|
|
101
|
+
api_key: key,
|
|
102
|
+
base_url: settings.unirate_base_url,
|
|
103
|
+
timeout: settings.unirate_timeout,
|
|
104
|
+
enable_historical: settings.unirate_enable_historical
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Sinatra registration hook. Sets default settings, mixes in the helpers,
|
|
109
|
+
# and — when :unirate_mount_routes is on — adds the JSON proxy routes.
|
|
110
|
+
def registered(app)
|
|
111
|
+
apply_defaults(app)
|
|
112
|
+
app.helpers(Helpers)
|
|
113
|
+
mount_routes(app)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def apply_defaults(app)
|
|
119
|
+
app.set :unirate_api_key, nil unless app.respond_to?(:unirate_api_key)
|
|
120
|
+
app.set :unirate_base_url, ::UniRate::SinatraClient::DEFAULT_BASE_URL unless app.respond_to?(:unirate_base_url)
|
|
121
|
+
app.set :unirate_timeout, ::UniRate::SinatraClient::DEFAULT_TIMEOUT unless app.respond_to?(:unirate_timeout)
|
|
122
|
+
app.set :unirate_default_currency, "USD" unless app.respond_to?(:unirate_default_currency)
|
|
123
|
+
app.set :unirate_enable_historical, false unless app.respond_to?(:unirate_enable_historical)
|
|
124
|
+
app.set :unirate_mount_routes, false unless app.respond_to?(:unirate_mount_routes)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Defines /unirate/rate, /unirate/convert, and /unirate/currencies JSON
|
|
128
|
+
# proxy routes. They are always declared at registration, but `pass` (fall
|
|
129
|
+
# through to a 404) unless :unirate_mount_routes is enabled — this lets the
|
|
130
|
+
# host app flip the setting *after* `register` (Sinatra evaluates route
|
|
131
|
+
# bodies per-request, not at registration). The API key never leaves the
|
|
132
|
+
# server; typed UniRate errors map back to their HTTP status.
|
|
133
|
+
def mount_routes(app)
|
|
134
|
+
app.get("/unirate/rate") do
|
|
135
|
+
unirate_proxy { { rate: unirate_client.get_rate(from: params["from"] || "USD", to: params["to"]) } }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
app.get("/unirate/convert") do
|
|
139
|
+
unirate_proxy do
|
|
140
|
+
result = unirate_client.convert(
|
|
141
|
+
to: params["to"], amount: Float(params["amount"] || 1), from: params["from"] || "USD"
|
|
142
|
+
)
|
|
143
|
+
{ result: result }
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
app.get("/unirate/currencies") do
|
|
148
|
+
unirate_proxy { { currencies: unirate_client.get_supported_currencies } }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Make the extension available to classic-style apps (`require "sinatra"`),
|
|
155
|
+
# which register into the top-level Sinatra::Application.
|
|
156
|
+
register UniRate if respond_to?(:register)
|
|
157
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path("lib", __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require "sinatra/unirate/version"
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = "sinatra-unirate"
|
|
9
|
+
spec.version = SinatraUniRate::VERSION
|
|
10
|
+
spec.authors = ["Unirate Team"]
|
|
11
|
+
spec.email = ["admin@unirateapi.com"]
|
|
12
|
+
|
|
13
|
+
spec.summary = "Sinatra extension for the UniRate API — currency exchange rates, conversion, and VAT."
|
|
14
|
+
spec.description = "A Sinatra extension wrapping the UniRate API " \
|
|
15
|
+
"(https://unirateapi.com) — free currency exchange rates, " \
|
|
16
|
+
"conversion, supported-currency listings, and VAT rates. " \
|
|
17
|
+
"Registers via `register Sinatra::UniRate` in classic or " \
|
|
18
|
+
"modular apps, exposes settings-based configuration, route/view " \
|
|
19
|
+
"helpers (unirate_rate, unirate_convert, unirate_currencies, " \
|
|
20
|
+
"unirate_vat), an optional JSON proxy mount, and a stdlib-only " \
|
|
21
|
+
"client with full error mapping. Zero runtime dependencies " \
|
|
22
|
+
"beyond Sinatra."
|
|
23
|
+
spec.homepage = "https://github.com/UniRate-API/sinatra-unirate"
|
|
24
|
+
spec.license = "MIT"
|
|
25
|
+
|
|
26
|
+
spec.required_ruby_version = ">= 3.0"
|
|
27
|
+
|
|
28
|
+
spec.metadata = {
|
|
29
|
+
"homepage_uri" => spec.homepage,
|
|
30
|
+
"source_code_uri" => spec.homepage,
|
|
31
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
32
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
33
|
+
"documentation_uri" => "https://unirateapi.com",
|
|
34
|
+
"rubygems_mfa_required" => "true"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
spec.files = Dir[
|
|
38
|
+
"lib/**/*.rb",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"sinatra-unirate.gemspec"
|
|
43
|
+
]
|
|
44
|
+
spec.require_paths = ["lib"]
|
|
45
|
+
|
|
46
|
+
spec.add_dependency "sinatra", ">= 2.0"
|
|
47
|
+
|
|
48
|
+
spec.add_development_dependency "rack-test", ">= 2.0"
|
|
49
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
50
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
51
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
52
|
+
spec.add_development_dependency "webmock", "~> 3.19"
|
|
53
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sinatra-unirate
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Unirate Team
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: sinatra
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '2.0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '2.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rack-test
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.0'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rspec
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.12'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.12'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: rubocop
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '1.60'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '1.60'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: webmock
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '3.19'
|
|
90
|
+
type: :development
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '3.19'
|
|
97
|
+
description: A Sinatra extension wrapping the UniRate API (https://unirateapi.com)
|
|
98
|
+
— free currency exchange rates, conversion, supported-currency listings, and VAT
|
|
99
|
+
rates. Registers via `register Sinatra::UniRate` in classic or modular apps, exposes
|
|
100
|
+
settings-based configuration, route/view helpers (unirate_rate, unirate_convert,
|
|
101
|
+
unirate_currencies, unirate_vat), an optional JSON proxy mount, and a stdlib-only
|
|
102
|
+
client with full error mapping. Zero runtime dependencies beyond Sinatra.
|
|
103
|
+
email:
|
|
104
|
+
- admin@unirateapi.com
|
|
105
|
+
executables: []
|
|
106
|
+
extensions: []
|
|
107
|
+
extra_rdoc_files: []
|
|
108
|
+
files:
|
|
109
|
+
- CHANGELOG.md
|
|
110
|
+
- LICENSE
|
|
111
|
+
- README.md
|
|
112
|
+
- lib/sinatra-unirate.rb
|
|
113
|
+
- lib/sinatra/unirate.rb
|
|
114
|
+
- lib/sinatra/unirate/client.rb
|
|
115
|
+
- lib/sinatra/unirate/errors.rb
|
|
116
|
+
- lib/sinatra/unirate/version.rb
|
|
117
|
+
- sinatra-unirate.gemspec
|
|
118
|
+
homepage: https://github.com/UniRate-API/sinatra-unirate
|
|
119
|
+
licenses:
|
|
120
|
+
- MIT
|
|
121
|
+
metadata:
|
|
122
|
+
homepage_uri: https://github.com/UniRate-API/sinatra-unirate
|
|
123
|
+
source_code_uri: https://github.com/UniRate-API/sinatra-unirate
|
|
124
|
+
bug_tracker_uri: https://github.com/UniRate-API/sinatra-unirate/issues
|
|
125
|
+
changelog_uri: https://github.com/UniRate-API/sinatra-unirate/blob/main/CHANGELOG.md
|
|
126
|
+
documentation_uri: https://unirateapi.com
|
|
127
|
+
rubygems_mfa_required: 'true'
|
|
128
|
+
post_install_message:
|
|
129
|
+
rdoc_options: []
|
|
130
|
+
require_paths:
|
|
131
|
+
- lib
|
|
132
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
133
|
+
requirements:
|
|
134
|
+
- - ">="
|
|
135
|
+
- !ruby/object:Gem::Version
|
|
136
|
+
version: '3.0'
|
|
137
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
138
|
+
requirements:
|
|
139
|
+
- - ">="
|
|
140
|
+
- !ruby/object:Gem::Version
|
|
141
|
+
version: '0'
|
|
142
|
+
requirements: []
|
|
143
|
+
rubygems_version: 3.5.22
|
|
144
|
+
signing_key:
|
|
145
|
+
specification_version: 4
|
|
146
|
+
summary: Sinatra extension for the UniRate API — currency exchange rates, conversion,
|
|
147
|
+
and VAT.
|
|
148
|
+
test_files: []
|