unirate-rails 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 +22 -0
- data/LICENSE +21 -0
- data/README.md +144 -0
- data/app/controllers/unirate_rails/rates_controller.rb +47 -0
- data/config/routes.rb +7 -0
- data/lib/unirate-rails.rb +5 -0
- data/lib/unirate_rails/client.rb +167 -0
- data/lib/unirate_rails/configuration.rb +39 -0
- data/lib/unirate_rails/engine.rb +28 -0
- data/lib/unirate_rails/errors.rb +31 -0
- data/lib/unirate_rails/helper.rb +29 -0
- data/lib/unirate_rails/version.rb +11 -0
- data/lib/unirate_rails.rb +45 -0
- data/unirate-rails.gemspec +52 -0
- metadata +151 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2d669d6f75495a08bc375cdc3dbf1f687122b08172cf46fe8ce08e91e3cd9f85
|
|
4
|
+
data.tar.gz: 1d05ad12bacfcfb5c0bd8feadebcb645049c875d36329d0f878b8b70a7e1d98c
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ad00bf1334a0073950d54512c3d80330ac672f368bcefbe79410aa109fc0aad785424b1eb9770095af9e545bb83fd2bd67a6b2af3c87da180cceb1b1cf3bb2a6
|
|
7
|
+
data.tar.gz: '0879332571fb8ecab13ff779e9e152ac33809a212e6f2699bfcd951f926a1b055989d1a3d068d758b5994b73d86ded3be4d511611b27f7c4e12f155b0624b5c4'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
- `UniRateRails::Engine` — a mountable, isolated Rails engine.
|
|
13
|
+
- `UniRateRails.configure { |c| ... }` process-wide configuration with an
|
|
14
|
+
`UNIRATE_API_KEY` env-var fallback for the API key.
|
|
15
|
+
- `UniRateRails::Client` — stdlib-only (`net/http` + `json`) client exposing
|
|
16
|
+
`get_rate`, `convert`, `get_supported_currencies`, `get_vat_rates`, and the
|
|
17
|
+
Pro-gated `get_historical_rate`, with full HTTP error mapping.
|
|
18
|
+
- ActionView helpers `unirate_rate` and `unirate_convert`, mixed into every
|
|
19
|
+
view render by the engine.
|
|
20
|
+
- Mountable JSON proxy controller: `GET rate`, `GET convert`, `GET currencies`.
|
|
21
|
+
- Historical endpoint feature-flagged off by default (Pro-gated / 403 on the
|
|
22
|
+
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,144 @@
|
|
|
1
|
+
# unirate-rails
|
|
2
|
+
|
|
3
|
+
A mountable [Rails engine](https://guides.rubyonrails.org/engines.html) 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 Rails app.
|
|
7
|
+
|
|
8
|
+
- `UniRateRails.configure { |c| c.api_key = ... }` block-style setup, or read
|
|
9
|
+
the key from `UNIRATE_API_KEY` automatically
|
|
10
|
+
- ActionView helpers — `unirate_rate` and `unirate_convert` — mixed into every
|
|
11
|
+
view render by the engine
|
|
12
|
+
- A mountable JSON proxy controller so your frontend never sees the API key
|
|
13
|
+
- `UniRateRails.client` — a plain client for controllers and service objects
|
|
14
|
+
- 170+ currencies (fiat + crypto) via UniRate
|
|
15
|
+
- Free tier, no credit card required
|
|
16
|
+
- **Zero runtime dependencies beyond Rails** (pure stdlib `net/http` + `json`)
|
|
17
|
+
|
|
18
|
+
> **Affiliation:** this engine is maintained by the UniRate team and talks to
|
|
19
|
+
> the UniRate API. If you only need euro-area rates the ECB feed may suit you
|
|
20
|
+
> better; for a broad multi-currency source on a free tier, UniRate is a good
|
|
21
|
+
> fit.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- Ruby 3.0+
|
|
26
|
+
- Rails (railties) 6.1+
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
# Gemfile
|
|
32
|
+
gem "unirate-rails"
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
bundle install
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick start
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
# config/initializers/unirate.rb
|
|
43
|
+
UniRateRails.configure do |c|
|
|
44
|
+
c.api_key = ENV.fetch("UNIRATE_API_KEY") # or leave nil to read the env var lazily
|
|
45
|
+
c.default_currency = "USD"
|
|
46
|
+
c.enable_historical = false # Pro-gated; leave off on the free tier
|
|
47
|
+
end
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Get a free API key at [unirateapi.com](https://unirateapi.com).
|
|
51
|
+
|
|
52
|
+
### View helpers
|
|
53
|
+
|
|
54
|
+
```erb
|
|
55
|
+
<p>1 USD = <%= unirate_rate("USD", "EUR") %> EUR</p>
|
|
56
|
+
<p>$100 = <%= unirate_convert(100, "USD", "EUR") %> EUR</p>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Both helpers return `nil` (instead of raising) on any UniRate error, so a
|
|
60
|
+
transient API hiccup never breaks a page render.
|
|
61
|
+
|
|
62
|
+
### JSON proxy endpoints
|
|
63
|
+
|
|
64
|
+
Mount the engine to expose server-side proxy endpoints — the API key stays on
|
|
65
|
+
the server:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
# config/routes.rb
|
|
69
|
+
mount UniRateRails::Engine => "/unirate"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
GET /unirate/rate?from=USD&to=EUR => { "rate": 0.92 }
|
|
74
|
+
GET /unirate/convert?from=USD&to=EUR&amount=100 => { "result": 92.5 }
|
|
75
|
+
GET /unirate/currencies => { "currencies": ["USD", ...] }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Direct client use
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
client = UniRateRails.client
|
|
82
|
+
|
|
83
|
+
client.get_rate(from: "USD", to: "EUR") # => 0.92
|
|
84
|
+
client.get_rate(from: "USD") # => { "EUR" => 0.92, "GBP" => 0.79, ... }
|
|
85
|
+
client.convert(to: "EUR", amount: 100) # => 92.5
|
|
86
|
+
client.get_supported_currencies # => ["USD", "EUR", "GBP", ...]
|
|
87
|
+
client.get_vat_rates(country: "DE") # => { "country_code" => "DE", "vat_rate" => 19.0 }
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
| Option | Default | Description |
|
|
93
|
+
|---------------------|---------------------------------|------------------------------------------------------|
|
|
94
|
+
| `api_key` | `ENV["UNIRATE_API_KEY"]` | Your UniRate API key. |
|
|
95
|
+
| `base_url` | `https://api.unirateapi.com` | API base URL. |
|
|
96
|
+
| `timeout` | `30` | HTTP open/read timeout in seconds. |
|
|
97
|
+
| `default_currency` | `"USD"` | Base used by the one-arg view helpers. |
|
|
98
|
+
| `enable_historical` | `false` | Enable the Pro-gated historical endpoint (see below).|
|
|
99
|
+
|
|
100
|
+
## Error handling
|
|
101
|
+
|
|
102
|
+
Every failure raises a subclass of `UniRateRails::UnirateError`:
|
|
103
|
+
|
|
104
|
+
| HTTP | Exception |
|
|
105
|
+
|-------|----------------------------------------|
|
|
106
|
+
| 400 | `UniRateRails::InvalidDateError` |
|
|
107
|
+
| 401 | `UniRateRails::AuthenticationError` |
|
|
108
|
+
| 403 | `UniRateRails::APIError` (status 403) |
|
|
109
|
+
| 404 | `UniRateRails::InvalidCurrencyError` |
|
|
110
|
+
| 429 | `UniRateRails::RateLimitError` |
|
|
111
|
+
| 503 | `UniRateRails::APIError` (status 503) |
|
|
112
|
+
| other | `UniRateRails::APIError` |
|
|
113
|
+
| network | `UniRateRails::UnirateError` (base) |
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
begin
|
|
117
|
+
UniRateRails.client.get_rate(from: "USD", to: "EUR")
|
|
118
|
+
rescue UniRateRails::RateLimitError
|
|
119
|
+
# back off and retry
|
|
120
|
+
rescue UniRateRails::UnirateError => e
|
|
121
|
+
Rails.logger.warn("UniRate: #{e.message}")
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Historical / VAT (Pro)
|
|
126
|
+
|
|
127
|
+
Historical rates are **Pro-gated** and return HTTP 403 on the free tier. They
|
|
128
|
+
are disabled by default; set `config.enable_historical = true` (and hold a Pro
|
|
129
|
+
subscription) to call `get_historical_rate`.
|
|
130
|
+
|
|
131
|
+
## Rate limits
|
|
132
|
+
|
|
133
|
+
The free tier is rate limited; a 429 raises `UniRateRails::RateLimitError`.
|
|
134
|
+
Cache responses in your app (e.g. `Rails.cache`) if you make frequent calls.
|
|
135
|
+
|
|
136
|
+
## Related clients
|
|
137
|
+
|
|
138
|
+
Part of the UniRate client family — see
|
|
139
|
+
[github.com/UniRate-API](https://github.com/UniRate-API) for Python, Node,
|
|
140
|
+
Swift, Java, Go, Rust, Ruby, PHP, and .NET clients plus framework integrations.
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UniRateRails
|
|
4
|
+
# JSON proxy endpoints for the UniRate API. Mount the engine to expose them:
|
|
5
|
+
#
|
|
6
|
+
# mount UniRateRails::Engine => "/unirate"
|
|
7
|
+
#
|
|
8
|
+
# then:
|
|
9
|
+
# GET /unirate/rate?from=USD&to=EUR
|
|
10
|
+
# GET /unirate/convert?from=USD&to=EUR&amount=100
|
|
11
|
+
# GET /unirate/currencies
|
|
12
|
+
#
|
|
13
|
+
# The API key never leaves the server — it lives in the engine configuration,
|
|
14
|
+
# not in the browser. Typed UniRate errors are mapped back to sensible HTTP
|
|
15
|
+
# statuses.
|
|
16
|
+
class RatesController < ActionController::Base
|
|
17
|
+
rescue_from UniRateRails::UnirateError, with: :render_unirate_error
|
|
18
|
+
|
|
19
|
+
def rate
|
|
20
|
+
render json: { rate: client.get_rate(from: params[:from] || "USD", to: params[:to]) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def convert
|
|
24
|
+
result = client.convert(
|
|
25
|
+
to: params[:to],
|
|
26
|
+
amount: Float(params[:amount] || 1),
|
|
27
|
+
from: params[:from] || "USD"
|
|
28
|
+
)
|
|
29
|
+
render json: { result: result }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def currencies
|
|
33
|
+
render json: { currencies: client.get_supported_currencies }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def client
|
|
39
|
+
UniRateRails.client
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def render_unirate_error(error)
|
|
43
|
+
status = error.respond_to?(:status) && error.status ? error.status : 502
|
|
44
|
+
render json: { error: error.message }, status: status
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module UniRateRails
|
|
8
|
+
# Stdlib-only HTTP client for the UniRate API (https://unirateapi.com).
|
|
9
|
+
#
|
|
10
|
+
# Zero runtime dependencies beyond Ruby's standard library (`net/http`,
|
|
11
|
+
# `json`, `uri`) — no third-party HTTP gem, so nothing extra to audit in a
|
|
12
|
+
# Rails app's dependency tree.
|
|
13
|
+
#
|
|
14
|
+
# Method names and parameter order mirror the canonical UniRate client spec.
|
|
15
|
+
# Currency and country codes are upcased before being sent.
|
|
16
|
+
class Client
|
|
17
|
+
def initialize(api_key: nil, base_url: nil, timeout: nil)
|
|
18
|
+
config = UniRateRails.configuration
|
|
19
|
+
@api_key = api_key || config.api_key
|
|
20
|
+
@base_url = base_url || config.base_url
|
|
21
|
+
@timeout = timeout || config.timeout
|
|
22
|
+
|
|
23
|
+
raise AuthenticationError.new("Missing or invalid API key", status: 401) if @api_key.nil? || @api_key.to_s.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# GET /api/rates — current rate(s).
|
|
27
|
+
# Returns a Float when +to+ is given, otherwise a { "EUR" => Float, ... } map.
|
|
28
|
+
def get_rate(from: "USD", to: nil, format: "json", callback: nil)
|
|
29
|
+
params = { "from" => up(from) }
|
|
30
|
+
params["to"] = up(to) if to
|
|
31
|
+
body = request("/api/rates", params, format: format, callback: callback)
|
|
32
|
+
return body unless json?(format)
|
|
33
|
+
|
|
34
|
+
if to
|
|
35
|
+
to_f(body.fetch("rate"))
|
|
36
|
+
else
|
|
37
|
+
floatify(body.fetch("rates", {}))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# GET /api/convert — current conversion. Returns a Float.
|
|
42
|
+
def convert(to:, amount: 1, from: "USD", format: "json", callback: nil)
|
|
43
|
+
params = { "from" => up(from), "to" => up(to), "amount" => amount }
|
|
44
|
+
body = request("/api/convert", params, format: format, callback: callback)
|
|
45
|
+
return body unless json?(format)
|
|
46
|
+
|
|
47
|
+
to_f(body.fetch("result"))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# GET /api/currencies — supported currency codes. Returns an Array of String.
|
|
51
|
+
def get_supported_currencies(format: "json", callback: nil)
|
|
52
|
+
body = request("/api/currencies", {}, format: format, callback: callback)
|
|
53
|
+
return body unless json?(format)
|
|
54
|
+
|
|
55
|
+
Array(body.fetch("currencies", []))
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# GET /api/vat/rates — VAT rates. Returns the whole map keyed by country when
|
|
59
|
+
# +country+ is nil, otherwise the single-country vat_data hash.
|
|
60
|
+
def get_vat_rates(country: nil, format: "json", callback: nil)
|
|
61
|
+
params = {}
|
|
62
|
+
params["country"] = up(country) if country
|
|
63
|
+
body = request("/api/vat/rates", params, format: format, callback: callback)
|
|
64
|
+
return body unless json?(format)
|
|
65
|
+
|
|
66
|
+
country ? body.fetch("vat_data") : body.fetch("vat_rates")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GET /api/historical/rates — Pro-gated (403 on free tier). Guarded by the
|
|
70
|
+
# `enable_historical` config flag so a free-tier app doesn't hit it by
|
|
71
|
+
# accident. Returns a Float or a { code => Float } map depending on args.
|
|
72
|
+
def get_historical_rate(date:, amount: 1, from: "USD", to: nil, format: "json", callback: nil)
|
|
73
|
+
ensure_historical_enabled!
|
|
74
|
+
params = { "date" => date, "from" => up(from), "amount" => amount }
|
|
75
|
+
params["to"] = up(to) if to
|
|
76
|
+
body = request("/api/historical/rates", params, format: format, callback: callback)
|
|
77
|
+
return body unless json?(format)
|
|
78
|
+
|
|
79
|
+
parse_historical(body, to: to)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def ensure_historical_enabled!
|
|
85
|
+
return if UniRateRails.configuration.enable_historical
|
|
86
|
+
|
|
87
|
+
raise APIError.new(
|
|
88
|
+
"Historical endpoints are Pro-gated and disabled; set config.enable_historical = true to use them",
|
|
89
|
+
status: 403
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_historical(body, to:)
|
|
94
|
+
if to
|
|
95
|
+
body.key?("result") ? to_f(body["result"]) : to_f(body.fetch("rate"))
|
|
96
|
+
else
|
|
97
|
+
floatify(body.fetch(body.key?("results") ? "results" : "rates", {}))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def request(path, params, format:, callback:)
|
|
102
|
+
uri = build_uri(path, params, format: format, callback: callback)
|
|
103
|
+
req = Net::HTTP::Get.new(uri)
|
|
104
|
+
req["Accept"] = "application/json"
|
|
105
|
+
req["User-Agent"] = "unirate-rails/#{VERSION}"
|
|
106
|
+
handle(perform(uri, req), format: format)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def build_uri(path, params, format:, callback:)
|
|
110
|
+
uri = URI.parse(@base_url)
|
|
111
|
+
uri.path = path
|
|
112
|
+
query = params.merge("api_key" => @api_key)
|
|
113
|
+
query["format"] = format if format && format != "json"
|
|
114
|
+
query["callback"] = callback if callback
|
|
115
|
+
uri.query = URI.encode_www_form(query)
|
|
116
|
+
uri
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def perform(uri, req)
|
|
120
|
+
Net::HTTP.start(uri.hostname, uri.port,
|
|
121
|
+
use_ssl: uri.scheme == "https",
|
|
122
|
+
open_timeout: @timeout,
|
|
123
|
+
read_timeout: @timeout) { |http| http.request(req) }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
raise UnirateError, "Network error talking to UniRate: #{e.message}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle(response, format:)
|
|
129
|
+
status = response.code.to_i
|
|
130
|
+
raise_for_status(status, response.body) unless (200..299).cover?(status)
|
|
131
|
+
|
|
132
|
+
return response.body.to_s unless json?(format)
|
|
133
|
+
|
|
134
|
+
JSON.parse(response.body.to_s)
|
|
135
|
+
rescue JSON::ParserError => e
|
|
136
|
+
raise UnirateError, "Failed to parse UniRate response: #{e.message}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def raise_for_status(status, body)
|
|
140
|
+
case status
|
|
141
|
+
when 400 then raise InvalidDateError.new("Invalid request parameters", status: 400)
|
|
142
|
+
when 401 then raise AuthenticationError.new("Missing or invalid API key", status: 401)
|
|
143
|
+
when 403 then raise APIError.new("Endpoint requires a Pro subscription", status: 403)
|
|
144
|
+
when 404 then raise InvalidCurrencyError.new("Currency not found or no data available", status: 404)
|
|
145
|
+
when 429 then raise RateLimitError.new("Rate limit exceeded", status: 429)
|
|
146
|
+
when 503 then raise APIError.new("Service unavailable", status: 503)
|
|
147
|
+
else raise APIError.new("UniRate API error (HTTP #{status}): #{body}", status: status)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def json?(format)
|
|
152
|
+
format.nil? || format == "json"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def up(value)
|
|
156
|
+
value.to_s.upcase
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def to_f(value)
|
|
160
|
+
Float(value)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def floatify(hash)
|
|
164
|
+
hash.each_with_object({}) { |(code, value), out| out[code.to_s.upcase] = to_f(value) }
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UniRateRails
|
|
4
|
+
# Process-wide configuration, populated in an initializer via
|
|
5
|
+
# {UniRateRails.configure}. The API key falls back to the `UNIRATE_API_KEY`
|
|
6
|
+
# environment variable so a secret never has to be committed to the host app.
|
|
7
|
+
#
|
|
8
|
+
# # config/initializers/unirate.rb
|
|
9
|
+
# UniRateRails.configure do |c|
|
|
10
|
+
# c.api_key = ENV.fetch("UNIRATE_API_KEY")
|
|
11
|
+
# c.default_currency = "USD"
|
|
12
|
+
# c.enable_historical = false
|
|
13
|
+
# end
|
|
14
|
+
class Configuration
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.unirateapi.com"
|
|
16
|
+
DEFAULT_TIMEOUT = 30
|
|
17
|
+
|
|
18
|
+
attr_accessor :base_url, :timeout, :default_currency, :enable_historical
|
|
19
|
+
attr_writer :api_key
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@api_key = nil
|
|
23
|
+
@base_url = DEFAULT_BASE_URL
|
|
24
|
+
@timeout = DEFAULT_TIMEOUT
|
|
25
|
+
@default_currency = "USD"
|
|
26
|
+
# Historical endpoints are Pro-gated (HTTP 403 on the free tier). Off by
|
|
27
|
+
# default so a free-tier app never surfaces a confusing 403; flip it on
|
|
28
|
+
# once the account has a Pro subscription.
|
|
29
|
+
@enable_historical = false
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Resolves from the explicit setter first, then the environment.
|
|
33
|
+
def api_key
|
|
34
|
+
return @api_key unless @api_key.nil? || @api_key.to_s.empty?
|
|
35
|
+
|
|
36
|
+
ENV.fetch("UNIRATE_API_KEY", nil)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
require "action_dispatch"
|
|
5
|
+
|
|
6
|
+
module UniRateRails
|
|
7
|
+
# Isolated, mountable Rails engine. Wiring it up does three things:
|
|
8
|
+
#
|
|
9
|
+
# 1. Namespaces the engine so its routes/controllers don't collide with the
|
|
10
|
+
# host app.
|
|
11
|
+
# 2. Mixes {UniRateRails::Helper} into every ActionView render, exposing the
|
|
12
|
+
# `unirate_rate` / `unirate_convert` view helpers.
|
|
13
|
+
#
|
|
14
|
+
# Mount the JSON proxy controller from the host app's routes if you want a
|
|
15
|
+
# ready-made endpoint:
|
|
16
|
+
#
|
|
17
|
+
# # config/routes.rb
|
|
18
|
+
# mount UniRateRails::Engine => "/unirate"
|
|
19
|
+
class Engine < ::Rails::Engine
|
|
20
|
+
isolate_namespace UniRateRails
|
|
21
|
+
|
|
22
|
+
initializer "unirate_rails.action_view" do
|
|
23
|
+
ActiveSupport.on_load(:action_view) do
|
|
24
|
+
include UniRateRails::Helper
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UniRateRails
|
|
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,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UniRateRails
|
|
4
|
+
# View helpers mixed into `ActionView::Base` by {UniRateRails::Engine} so
|
|
5
|
+
# templates can look up rates and conversions inline. Each helper builds a
|
|
6
|
+
# short-lived {Client} from the process configuration.
|
|
7
|
+
#
|
|
8
|
+
# <%= unirate_rate("USD", "EUR") %> => 0.92
|
|
9
|
+
# <%= unirate_convert(100, "USD", "EUR") %> => 92.5
|
|
10
|
+
module Helper
|
|
11
|
+
# Current rate from +from+ to +to+ (a Float), or nil on any UniRate error
|
|
12
|
+
# so a rate hiccup never raises inside a view render.
|
|
13
|
+
def unirate_rate(from = nil, to = nil)
|
|
14
|
+
from ||= UniRateRails.configuration.default_currency
|
|
15
|
+
UniRateRails.client.get_rate(from: from, to: to)
|
|
16
|
+
rescue UniRateRails::UnirateError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Convert +amount+ from +from+ to +to+ (a Float), or nil on any UniRate
|
|
21
|
+
# error.
|
|
22
|
+
def unirate_convert(amount, from = nil, to = nil)
|
|
23
|
+
from ||= UniRateRails.configuration.default_currency
|
|
24
|
+
UniRateRails.client.convert(to: to, amount: amount, from: from)
|
|
25
|
+
rescue UniRateRails::UnirateError
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
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 `Rails::Engine` (or other runtime-class) reopening:
|
|
5
|
+
# the gemspec `require_relative`s this file at build time, *before* the runtime
|
|
6
|
+
# dependencies (railties) are installed. Opening a module that later becomes a
|
|
7
|
+
# subclass of something Rails-defined would blow up with a "superclass
|
|
8
|
+
# mismatch" TypeError during `gem build`.
|
|
9
|
+
module UniRateRails
|
|
10
|
+
VERSION = "0.1.0"
|
|
11
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "unirate_rails/version"
|
|
4
|
+
require_relative "unirate_rails/errors"
|
|
5
|
+
require_relative "unirate_rails/configuration"
|
|
6
|
+
require_relative "unirate_rails/client"
|
|
7
|
+
require_relative "unirate_rails/helper"
|
|
8
|
+
|
|
9
|
+
# UniRate for Rails — a mountable Rails engine wrapping the UniRate API
|
|
10
|
+
# (https://unirateapi.com) for free currency exchange rates, conversion,
|
|
11
|
+
# supported-currency listings, and VAT rates.
|
|
12
|
+
#
|
|
13
|
+
# UniRateRails.configure { |c| c.api_key = ENV.fetch("UNIRATE_API_KEY") }
|
|
14
|
+
# UniRateRails.client.get_rate(from: "USD", to: "EUR") # => 0.92
|
|
15
|
+
module UniRateRails
|
|
16
|
+
class << self
|
|
17
|
+
# The process-wide {Configuration}. Lazily created on first access.
|
|
18
|
+
def configuration
|
|
19
|
+
@configuration ||= Configuration.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Yields the configuration for block-style setup, typically from a Rails
|
|
23
|
+
# initializer.
|
|
24
|
+
def configure
|
|
25
|
+
yield(configuration) if block_given?
|
|
26
|
+
configuration
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resets configuration to defaults (mainly useful in tests).
|
|
30
|
+
def reset_configuration!
|
|
31
|
+
@configuration = Configuration.new
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A {Client} built from the current configuration. A fresh instance is
|
|
35
|
+
# returned each call so config changes are always picked up.
|
|
36
|
+
def client
|
|
37
|
+
Client.new
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The engine pulls in railties, so only load it when Rails is present. This
|
|
43
|
+
# keeps the core client usable outside a Rails app (and keeps `gem build`,
|
|
44
|
+
# which runs before deps install, from needing Rails on the load path).
|
|
45
|
+
require_relative "unirate_rails/engine" if defined?(Rails::Engine)
|
|
@@ -0,0 +1,52 @@
|
|
|
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 "unirate_rails/version"
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = "unirate-rails"
|
|
9
|
+
spec.version = UniRateRails::VERSION
|
|
10
|
+
spec.authors = ["Unirate Team"]
|
|
11
|
+
spec.email = ["admin@unirateapi.com"]
|
|
12
|
+
|
|
13
|
+
spec.summary = "Rails engine for the UniRate API — currency exchange rates, conversion, and VAT."
|
|
14
|
+
spec.description = "A mountable Rails engine wrapping the UniRate API " \
|
|
15
|
+
"(https://unirateapi.com) — free currency exchange rates, " \
|
|
16
|
+
"conversion, supported-currency listings, and VAT rates. " \
|
|
17
|
+
"Ships a configurable client, ActionView helpers " \
|
|
18
|
+
"(unirate_rate, unirate_convert), and a JSON proxy " \
|
|
19
|
+
"controller. Zero runtime dependencies beyond Rails."
|
|
20
|
+
spec.homepage = "https://github.com/UniRate-API/unirate-rails"
|
|
21
|
+
spec.license = "MIT"
|
|
22
|
+
|
|
23
|
+
spec.required_ruby_version = ">= 3.0"
|
|
24
|
+
|
|
25
|
+
spec.metadata = {
|
|
26
|
+
"homepage_uri" => spec.homepage,
|
|
27
|
+
"source_code_uri" => spec.homepage,
|
|
28
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
29
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
30
|
+
"documentation_uri" => "https://unirateapi.com",
|
|
31
|
+
"rubygems_mfa_required" => "true"
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
spec.files = Dir[
|
|
35
|
+
"lib/**/*.rb",
|
|
36
|
+
"app/**/*.rb",
|
|
37
|
+
"config/**/*.rb",
|
|
38
|
+
"README.md",
|
|
39
|
+
"CHANGELOG.md",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"unirate-rails.gemspec"
|
|
42
|
+
]
|
|
43
|
+
spec.require_paths = ["lib"]
|
|
44
|
+
|
|
45
|
+
spec.add_dependency "railties", ">= 6.1"
|
|
46
|
+
|
|
47
|
+
spec.add_development_dependency "actionpack", ">= 6.1"
|
|
48
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
49
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
50
|
+
spec.add_development_dependency "rubocop", "~> 1.60"
|
|
51
|
+
spec.add_development_dependency "webmock", "~> 3.19"
|
|
52
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: unirate-rails
|
|
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-27 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '6.1'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '6.1'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: actionpack
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '6.1'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '6.1'
|
|
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 mountable Rails engine wrapping the UniRate API (https://unirateapi.com)
|
|
98
|
+
— free currency exchange rates, conversion, supported-currency listings, and VAT
|
|
99
|
+
rates. Ships a configurable client, ActionView helpers (unirate_rate, unirate_convert),
|
|
100
|
+
and a JSON proxy controller. Zero runtime dependencies beyond Rails.
|
|
101
|
+
email:
|
|
102
|
+
- admin@unirateapi.com
|
|
103
|
+
executables: []
|
|
104
|
+
extensions: []
|
|
105
|
+
extra_rdoc_files: []
|
|
106
|
+
files:
|
|
107
|
+
- CHANGELOG.md
|
|
108
|
+
- LICENSE
|
|
109
|
+
- README.md
|
|
110
|
+
- app/controllers/unirate_rails/rates_controller.rb
|
|
111
|
+
- config/routes.rb
|
|
112
|
+
- lib/unirate-rails.rb
|
|
113
|
+
- lib/unirate_rails.rb
|
|
114
|
+
- lib/unirate_rails/client.rb
|
|
115
|
+
- lib/unirate_rails/configuration.rb
|
|
116
|
+
- lib/unirate_rails/engine.rb
|
|
117
|
+
- lib/unirate_rails/errors.rb
|
|
118
|
+
- lib/unirate_rails/helper.rb
|
|
119
|
+
- lib/unirate_rails/version.rb
|
|
120
|
+
- unirate-rails.gemspec
|
|
121
|
+
homepage: https://github.com/UniRate-API/unirate-rails
|
|
122
|
+
licenses:
|
|
123
|
+
- MIT
|
|
124
|
+
metadata:
|
|
125
|
+
homepage_uri: https://github.com/UniRate-API/unirate-rails
|
|
126
|
+
source_code_uri: https://github.com/UniRate-API/unirate-rails
|
|
127
|
+
bug_tracker_uri: https://github.com/UniRate-API/unirate-rails/issues
|
|
128
|
+
changelog_uri: https://github.com/UniRate-API/unirate-rails/blob/main/CHANGELOG.md
|
|
129
|
+
documentation_uri: https://unirateapi.com
|
|
130
|
+
rubygems_mfa_required: 'true'
|
|
131
|
+
post_install_message:
|
|
132
|
+
rdoc_options: []
|
|
133
|
+
require_paths:
|
|
134
|
+
- lib
|
|
135
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
136
|
+
requirements:
|
|
137
|
+
- - ">="
|
|
138
|
+
- !ruby/object:Gem::Version
|
|
139
|
+
version: '3.0'
|
|
140
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - ">="
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0'
|
|
145
|
+
requirements: []
|
|
146
|
+
rubygems_version: 3.5.22
|
|
147
|
+
signing_key:
|
|
148
|
+
specification_version: 4
|
|
149
|
+
summary: Rails engine for the UniRate API — currency exchange rates, conversion, and
|
|
150
|
+
VAT.
|
|
151
|
+
test_files: []
|