resteze 0.3.1 → 1.0.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 +4 -4
- data/.rubocop.yml +2 -0
- data/CHANGELOG.md +38 -0
- data/CLAUDE.md +83 -0
- data/README.md +75 -3
- data/docs/ACTIONS.md +329 -0
- data/docs/ADVANCED_USAGE.md +744 -0
- data/docs/API.md +404 -0
- data/docs/CONFIGURATION.md +962 -0
- data/docs/ERROR_HANDLING.md +676 -0
- data/docs/TESTING.md +789 -0
- data/lib/resteze/action.rb +99 -0
- data/lib/resteze/actions/validation.rb +56 -0
- data/lib/resteze/actions.rb +117 -0
- data/lib/resteze/api_module.rb +11 -5
- data/lib/resteze/api_resource.rb +28 -3
- data/lib/resteze/authenticating.rb +28 -0
- data/lib/resteze/authenticator/api_key.rb +45 -0
- data/lib/resteze/authenticator/basic.rb +40 -0
- data/lib/resteze/authenticator/bearer.rb +37 -0
- data/lib/resteze/authenticator/mtls.rb +73 -0
- data/lib/resteze/authenticator/strategy.rb +133 -0
- data/lib/resteze/authenticator.rb +187 -0
- data/lib/resteze/client.rb +60 -11
- data/lib/resteze/errors.rb +125 -4
- data/lib/resteze/instrumentation.rb +49 -0
- data/lib/resteze/list.rb +2 -2
- data/lib/resteze/middleware/raise_error.rb +38 -6
- data/lib/resteze/rate_limit.rb +44 -0
- data/lib/resteze/rate_limiter/fixed_window.rb +107 -0
- data/lib/resteze/rate_limiter/leaky_bucket.rb +40 -0
- data/lib/resteze/rate_limiter/sliding_window.rb +37 -0
- data/lib/resteze/rate_limiter/strategy.rb +58 -0
- data/lib/resteze/rate_limiter/token_bucket.rb +39 -0
- data/lib/resteze/rate_limiter.rb +229 -0
- data/lib/resteze/rate_limiting.rb +30 -0
- data/lib/resteze/response.rb +37 -1
- data/lib/resteze/result.rb +18 -0
- data/lib/resteze/save.rb +25 -3
- data/lib/resteze/synchronized_state.rb +29 -0
- data/lib/resteze/testing/minitest/configuration.rb +3 -1
- data/lib/resteze/testing/minitest/object.rb +30 -0
- data/lib/resteze/testing/rspec/configure.rb +3 -1
- data/lib/resteze/testing/rspec/object.rb +32 -2
- data/lib/resteze/version.rb +1 -1
- data/lib/resteze.rb +102 -10
- metadata +30 -2
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
# Configuration Guide
|
|
2
|
+
|
|
3
|
+
## Table of Contents
|
|
4
|
+
- [Basic Configuration](#basic-configuration)
|
|
5
|
+
- [Authentication](#authentication)
|
|
6
|
+
- [Environment-based Configuration](#environment-based-configuration)
|
|
7
|
+
- [Dynamic Configuration](#dynamic-configuration)
|
|
8
|
+
- [Connection Configuration](#connection-configuration)
|
|
9
|
+
- [Logging Configuration](#logging-configuration)
|
|
10
|
+
- [Proxy Configuration](#proxy-configuration)
|
|
11
|
+
- [Rate Limits](#rate-limits)
|
|
12
|
+
- [Advanced Configuration Patterns](#advanced-configuration-patterns)
|
|
13
|
+
|
|
14
|
+
## Basic Configuration
|
|
15
|
+
|
|
16
|
+
### Minimal Setup
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
require 'resteze'
|
|
20
|
+
|
|
21
|
+
module MyApi
|
|
22
|
+
include Resteze
|
|
23
|
+
|
|
24
|
+
configure do |config|
|
|
25
|
+
config.api_base = 'https://api.example.com/'
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Standard Configuration
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
module MyApi
|
|
34
|
+
include Resteze
|
|
35
|
+
|
|
36
|
+
configure do |config|
|
|
37
|
+
# API endpoint
|
|
38
|
+
config.api_base = 'https://api.example.com/'
|
|
39
|
+
|
|
40
|
+
# Timeouts (in seconds)
|
|
41
|
+
config.open_timeout = 30 # Connection timeout
|
|
42
|
+
config.read_timeout = 60 # Read timeout
|
|
43
|
+
|
|
44
|
+
# Logging
|
|
45
|
+
config.logger = Logger.new($stdout)
|
|
46
|
+
|
|
47
|
+
# Proxy (optional)
|
|
48
|
+
config.proxy = ENV['HTTP_PROXY']
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Custom Configuration Properties
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
module MyApi
|
|
57
|
+
include Resteze
|
|
58
|
+
|
|
59
|
+
# Define custom properties
|
|
60
|
+
class << self
|
|
61
|
+
attr_accessor :api_key,
|
|
62
|
+
:api_secret,
|
|
63
|
+
:environment,
|
|
64
|
+
:rate_limit,
|
|
65
|
+
:retry_count,
|
|
66
|
+
:cache_ttl
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
configure do |config|
|
|
70
|
+
# Standard Resteze config
|
|
71
|
+
config.api_base = 'https://api.example.com/'
|
|
72
|
+
config.open_timeout = 30
|
|
73
|
+
config.read_timeout = 60
|
|
74
|
+
|
|
75
|
+
# Custom config
|
|
76
|
+
config.api_key = ENV['API_KEY']
|
|
77
|
+
config.api_secret = ENV['API_SECRET']
|
|
78
|
+
config.environment = ENV['API_ENV'] || 'production'
|
|
79
|
+
config.rate_limit = 100 # requests per minute
|
|
80
|
+
config.retry_count = 3
|
|
81
|
+
config.cache_ttl = 300 # 5 minutes
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Authentication
|
|
87
|
+
|
|
88
|
+
Each module gets a `MyApi::Authenticator`, a subclass of `Resteze::Authenticator` that does nothing until you choose a scheme with `authenticate_with` on the module. A scheme is a `Resteze::Authenticator::Strategy` subclass; it expects its credential in a conventional module setting, declares that setting on the module when chosen, and reads it on every request, so an initializer fills it through `configure` like any other setting and a rotated value takes effect without a restart. A setting that is still `nil` when a request goes out raises an `ArgumentError` naming it rather than sending an empty credential; `MyApi.authenticator.missing_settings` lists them for a boot-time check.
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
module MyApi
|
|
92
|
+
include Resteze
|
|
93
|
+
|
|
94
|
+
authenticate_with :api_key, header: 'X-Api-Key' # expects MyApi.api_key
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# config/initializers/my_api.rb
|
|
98
|
+
MyApi.configure do |config|
|
|
99
|
+
config.api_key = ENV['MY_API_KEY']
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
The convention is a default. Any credential option also takes a Symbol naming a different setting (`value: :partner_key`), a callable (`value: -> { TokenStore.current }`), or a literal. Schemes combine; call `authenticate_with` once per scheme. (`strategy` inside `class Authenticator` is the same thing, for when you are reopening the class anyway to override its lifecycle.)
|
|
104
|
+
|
|
105
|
+
### Built-in schemes
|
|
106
|
+
|
|
107
|
+
| Scheme | Expects | Options | Sends |
|
|
108
|
+
|---|---|---|---|
|
|
109
|
+
| `:api_key` | `api_key` | `value:`, and `header:` (default `X-Api-Key`) or `query:` | the key in that header, or in the query string on every request whatever the method |
|
|
110
|
+
| `:basic` | `username`, `password` | `username:`, `password:` | `Authorization: Basic …` |
|
|
111
|
+
| `:bearer` | `api_key` | `token:`, `scheme:` (default `Bearer`) | `Authorization: Bearer …`; `scheme: 'Token'` for another prefix |
|
|
112
|
+
| `:mtls` | `client_cert`, `client_key`; optionally `client_key_passphrase`, `ca_file` | `cert:`, `key:`, `passphrase:`, `ca_file:`, `verify:` | a client certificate on the TLS connection; nothing on the requests |
|
|
113
|
+
|
|
114
|
+
`cert:` and `key:` for `:mtls` are PEM strings or paths to PEM files, from the settings, a callable, or a literal. A path is read as given when the connection is first built, so make it absolute (`Rails.root.join(...)`); a relative path resolves against whatever the process's working directory happens to be at that moment. The certificate is applied when `Client.default_connection` builds the connection, so an override of that method must pass `connection_options` to Faraday itself. `missing_settings` leaves a scheme's optional settings alone, so an unencrypted key with no passphrase is not reported as missing. `verify: false` logs a warning through the module's logger every time a connection is built with it.
|
|
115
|
+
|
|
116
|
+
### The lifecycle
|
|
117
|
+
|
|
118
|
+
The client calls the authenticator at three points: `connection_options` when the per-thread connection is built (an override of `Client.default_connection` must pass them to Faraday itself), `apply(request)` before every request (with `headers`, `params` and `query` to mutate; `query` always goes in the query string whatever the method), and `refresh?(error, attempt)` after a `MyApi::AuthenticationError`, where returning `true` sends the request once more with a renewed credential.
|
|
119
|
+
|
|
120
|
+
### Credentials and the log
|
|
121
|
+
|
|
122
|
+
At `Logger::DEBUG` the client logs the whole request, headers and query string included. Each strategy names where it put the credential through `sensitive_keys` (`{ headers: [...], query: [...], params: [...] }`), and the client replaces those values with `[REDACTED]` in the logged copy only; the request goes out intact. The built-ins all declare theirs. `Authorization` and `Proxy-Authorization` are masked whatever the strategies say, which also covers a credential added through an overridden `request_headers`.
|
|
123
|
+
|
|
124
|
+
### Writing a scheme
|
|
125
|
+
|
|
126
|
+
Subclass `Resteze::Authenticator::Strategy` and implement `apply(request, state)`; add `connection_options` or `refresh?(error, attempt, state)` when needed. A `DEFAULTS` constant whose Symbol values name module settings makes `authenticate_with` declare those settings; list in `OPTIONAL` any the scheme works without, and `missing_settings` will not report them. `resolve` reads an option whether it is a Symbol (from the module), a callable, or a literal; `resolve!` does the same and raises a clear `ArgumentError` on `nil`; `required!(name, value)` in a constructor rejects `nil` for an option that must be given. Implement `sensitive_keys` to name the header, query or body keys the credential lands under, or it will appear in the `DEBUG` log. `state` is a hash shared by every thread and every strategy on the authenticator, already under its lock, so a cached credential needs no synchronisation of its own; prefix your keys with the scheme's name. Options after the class go to your constructor.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
module MyApi
|
|
130
|
+
class SessionToken < Resteze::Authenticator::Strategy
|
|
131
|
+
DEFAULTS = { username: :username, password: :password }.freeze # the settings this scheme expects
|
|
132
|
+
|
|
133
|
+
def initialize(login_url:, username: :username, password: :password)
|
|
134
|
+
super()
|
|
135
|
+
@login_url, @username, @password = login_url, username, password
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def apply(request, state)
|
|
139
|
+
state[:session_token] ||= login
|
|
140
|
+
request.headers['X-Session-Token'] = state[:session_token]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def refresh?(_error, attempt, state)
|
|
144
|
+
return false if attempt > 1
|
|
145
|
+
|
|
146
|
+
state[:session_token] = login
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def sensitive_keys
|
|
151
|
+
{ headers: ['X-Session-Token'] } # masked in the DEBUG log
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def login
|
|
157
|
+
body = { username: resolve!(:username, @username), password: resolve!(:password, @password) }.to_json
|
|
158
|
+
JSON.parse(Faraday.post(@login_url, body, 'Content-Type' => 'application/json').body).fetch('token')
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
authenticate_with SessionToken, login_url: 'https://api.example.com/login' # expects MyApi.username and .password
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`Resteze::Authenticator::Strategy.register(:session_token, SessionToken)` gives a scheme a name, so `authenticate_with :session_token` works like a built-in; a gem that ships a scheme registers it this way.
|
|
167
|
+
|
|
168
|
+
`MyApi.authenticator` is the instance the client uses; assign another to replace it (tests do this to swap credentials) or `nil` to restore the default. `Client#request_headers` remains overridable for headers that are not a credential.
|
|
169
|
+
|
|
170
|
+
## Environment-based Configuration
|
|
171
|
+
|
|
172
|
+
### Using Rails Environments
|
|
173
|
+
|
|
174
|
+
```ruby
|
|
175
|
+
module MyApi
|
|
176
|
+
include Resteze
|
|
177
|
+
|
|
178
|
+
configure do |config|
|
|
179
|
+
case Rails.env
|
|
180
|
+
when 'production'
|
|
181
|
+
config.api_base = 'https://api.example.com/'
|
|
182
|
+
config.logger = Rails.logger
|
|
183
|
+
config.open_timeout = 30
|
|
184
|
+
config.read_timeout = 60
|
|
185
|
+
when 'staging'
|
|
186
|
+
config.api_base = 'https://staging-api.example.com/'
|
|
187
|
+
config.logger = Rails.logger
|
|
188
|
+
config.open_timeout = 45
|
|
189
|
+
config.read_timeout = 90
|
|
190
|
+
when 'development'
|
|
191
|
+
config.api_base = 'http://localhost:3000/'
|
|
192
|
+
config.logger = Logger.new($stdout)
|
|
193
|
+
config.logger.level = Logger::DEBUG
|
|
194
|
+
config.open_timeout = 60
|
|
195
|
+
config.read_timeout = 120
|
|
196
|
+
when 'test'
|
|
197
|
+
config.api_base = 'http://test.local/'
|
|
198
|
+
config.logger = Logger.new(nil) # Disable logging in tests
|
|
199
|
+
config.open_timeout = 5
|
|
200
|
+
config.read_timeout = 5
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Using Environment Variables
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
module MyApi
|
|
210
|
+
include Resteze
|
|
211
|
+
|
|
212
|
+
configure do |config|
|
|
213
|
+
# Required environment variables
|
|
214
|
+
config.api_base = ENV.fetch('MY_API_URL') do
|
|
215
|
+
raise "MY_API_URL environment variable is required"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Optional with defaults
|
|
219
|
+
config.open_timeout = ENV.fetch('MY_API_OPEN_TIMEOUT', 30).to_i
|
|
220
|
+
config.read_timeout = ENV.fetch('MY_API_READ_TIMEOUT', 60).to_i
|
|
221
|
+
|
|
222
|
+
# Conditional configuration
|
|
223
|
+
if ENV['MY_API_DEBUG'] == 'true'
|
|
224
|
+
config.logger = Logger.new($stdout)
|
|
225
|
+
config.logger.level = Logger::DEBUG
|
|
226
|
+
else
|
|
227
|
+
config.logger = Logger.new('log/my_api.log')
|
|
228
|
+
config.logger.level = Logger::INFO
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Proxy configuration
|
|
232
|
+
config.proxy = ENV['HTTP_PROXY'] if ENV['HTTP_PROXY']
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Using Configuration Files
|
|
238
|
+
|
|
239
|
+
```ruby
|
|
240
|
+
# config/my_api.yml
|
|
241
|
+
production:
|
|
242
|
+
api_base: "https://api.example.com/"
|
|
243
|
+
api_key: <%= ENV['MY_API_KEY'] %>
|
|
244
|
+
open_timeout: 30
|
|
245
|
+
read_timeout: 60
|
|
246
|
+
|
|
247
|
+
development:
|
|
248
|
+
api_base: "http://localhost:3000/"
|
|
249
|
+
api_key: "development-key"
|
|
250
|
+
open_timeout: 60
|
|
251
|
+
read_timeout: 120
|
|
252
|
+
|
|
253
|
+
test:
|
|
254
|
+
api_base: "http://test.local/"
|
|
255
|
+
api_key: "test-key"
|
|
256
|
+
open_timeout: 5
|
|
257
|
+
read_timeout: 5
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
# Load configuration from YAML
|
|
262
|
+
require 'yaml'
|
|
263
|
+
require 'erb'
|
|
264
|
+
|
|
265
|
+
module MyApi
|
|
266
|
+
include Resteze
|
|
267
|
+
|
|
268
|
+
# Load config file
|
|
269
|
+
config_file = File.join(Rails.root, 'config', 'my_api.yml')
|
|
270
|
+
config_data = YAML.safe_load(ERB.new(File.read(config_file)).result)
|
|
271
|
+
settings = config_data[Rails.env]
|
|
272
|
+
|
|
273
|
+
configure do |config|
|
|
274
|
+
settings.each do |key, value|
|
|
275
|
+
config.send("#{key}=", value)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## Dynamic Configuration
|
|
282
|
+
|
|
283
|
+
### Runtime Configuration Changes
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
module MyApi
|
|
287
|
+
include Resteze
|
|
288
|
+
|
|
289
|
+
class << self
|
|
290
|
+
def reconfigure
|
|
291
|
+
yield self if block_given?
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def reset_configuration!
|
|
295
|
+
configure do |config|
|
|
296
|
+
config.api_base = default_api_base
|
|
297
|
+
config.open_timeout = 30
|
|
298
|
+
config.read_timeout = 60
|
|
299
|
+
config.logger = Logger.new($stdout)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def default_api_base
|
|
306
|
+
'https://api.example.com/'
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Usage
|
|
312
|
+
MyApi.reconfigure do |config|
|
|
313
|
+
config.api_base = 'https://new-api.example.com/'
|
|
314
|
+
config.api_key = 'new-key'
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Reset to defaults
|
|
318
|
+
MyApi.reset_configuration!
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Per-Request Configuration
|
|
322
|
+
|
|
323
|
+
```ruby
|
|
324
|
+
module MyApi
|
|
325
|
+
class Client < Resteze::Client
|
|
326
|
+
def with_timeout(open: nil, read: nil)
|
|
327
|
+
old_open = api_module.open_timeout
|
|
328
|
+
old_read = api_module.read_timeout
|
|
329
|
+
|
|
330
|
+
api_module.open_timeout = open if open
|
|
331
|
+
api_module.read_timeout = read if read
|
|
332
|
+
|
|
333
|
+
yield
|
|
334
|
+
ensure
|
|
335
|
+
api_module.open_timeout = old_open
|
|
336
|
+
api_module.read_timeout = old_read
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
class User < ApiResource
|
|
341
|
+
# Use custom timeout for slow endpoints
|
|
342
|
+
def self.export_all
|
|
343
|
+
Client.active_client.with_timeout(read: 300) do
|
|
344
|
+
request(:get, "#{resource_path}/export")
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
### Multi-tenant Configuration
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
module MyApi
|
|
355
|
+
include Resteze
|
|
356
|
+
|
|
357
|
+
class << self
|
|
358
|
+
def for_tenant(tenant_id)
|
|
359
|
+
Thread.current[:my_api_tenant] = tenant_id
|
|
360
|
+
yield
|
|
361
|
+
ensure
|
|
362
|
+
Thread.current[:my_api_tenant] = nil
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def current_tenant
|
|
366
|
+
Thread.current[:my_api_tenant]
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
class Client < Resteze::Client
|
|
371
|
+
def request_headers
|
|
372
|
+
headers = super
|
|
373
|
+
if api_module.current_tenant
|
|
374
|
+
headers['X-Tenant-ID'] = api_module.current_tenant
|
|
375
|
+
end
|
|
376
|
+
headers
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def self.api_url(path = "")
|
|
380
|
+
base = if api_module.current_tenant
|
|
381
|
+
"https://#{api_module.current_tenant}.api.example.com/"
|
|
382
|
+
else
|
|
383
|
+
api_module.api_base
|
|
384
|
+
end
|
|
385
|
+
[base.chomp("/"), path].join
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Usage
|
|
391
|
+
MyApi.for_tenant('acme') do
|
|
392
|
+
user = MyApi::User.retrieve('123') # Uses acme.api.example.com
|
|
393
|
+
end
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
## Connection Configuration
|
|
397
|
+
|
|
398
|
+
### Faraday Connection Setup
|
|
399
|
+
|
|
400
|
+
```ruby
|
|
401
|
+
module MyApi
|
|
402
|
+
class Client < Resteze::Client
|
|
403
|
+
def self.default_connection
|
|
404
|
+
@default_connection ||= Faraday.new do |conn|
|
|
405
|
+
# Request middleware (order matters!)
|
|
406
|
+
conn.request :json # Encode request bodies as JSON
|
|
407
|
+
conn.request :retry, max: 3, interval: 0.5 # Retry failed requests
|
|
408
|
+
conn.request :authorization, 'Bearer', -> { api_module.api_key }
|
|
409
|
+
|
|
410
|
+
# Response middleware
|
|
411
|
+
conn.response :json, content_type: /\bjson$/ # Parse JSON responses
|
|
412
|
+
conn.response :logger, api_module.logger, bodies: true # Log requests
|
|
413
|
+
|
|
414
|
+
# Error handling
|
|
415
|
+
conn.use Middleware::RaiseError
|
|
416
|
+
|
|
417
|
+
# Adapter (must be last)
|
|
418
|
+
conn.adapter :net_http_persistent # Use persistent connections
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
### SSL/TLS Configuration
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
module MyApi
|
|
429
|
+
class Client < Resteze::Client
|
|
430
|
+
def self.default_connection
|
|
431
|
+
@default_connection ||= Faraday.new(ssl: ssl_options) do |conn|
|
|
432
|
+
conn.use Middleware::RaiseError
|
|
433
|
+
conn.adapter Faraday.default_adapter
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
private
|
|
438
|
+
|
|
439
|
+
def self.ssl_options
|
|
440
|
+
{
|
|
441
|
+
# Certificate verification
|
|
442
|
+
verify: true, # Verify SSL certificates
|
|
443
|
+
|
|
444
|
+
# Client certificates
|
|
445
|
+
client_cert: OpenSSL::X509::Certificate.new(File.read('client.crt')),
|
|
446
|
+
client_key: OpenSSL::PKey::RSA.new(File.read('client.key')),
|
|
447
|
+
|
|
448
|
+
# CA bundle
|
|
449
|
+
ca_file: '/path/to/ca_bundle.pem',
|
|
450
|
+
|
|
451
|
+
# Minimum TLS version
|
|
452
|
+
version: :TLSv1_2,
|
|
453
|
+
|
|
454
|
+
# Cipher suites
|
|
455
|
+
ciphers: 'HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!SRP:!CAMELLIA'
|
|
456
|
+
}
|
|
457
|
+
rescue => e
|
|
458
|
+
Rails.logger.error "Failed to load SSL certificates: #{e.message}"
|
|
459
|
+
{ verify: true } # Fall back to default verification
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### Custom Adapters
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
module MyApi
|
|
469
|
+
class Client < Resteze::Client
|
|
470
|
+
def self.default_connection
|
|
471
|
+
@default_connection ||= Faraday.new do |conn|
|
|
472
|
+
conn.use Middleware::RaiseError
|
|
473
|
+
|
|
474
|
+
# Choose adapter based on environment
|
|
475
|
+
case Rails.env
|
|
476
|
+
when 'production'
|
|
477
|
+
# Use Typhoeus for better performance
|
|
478
|
+
conn.adapter :typhoeus
|
|
479
|
+
when 'test'
|
|
480
|
+
# Use test adapter for testing
|
|
481
|
+
conn.adapter :test do |stub|
|
|
482
|
+
stub.get('/users/123') { [200, {}, { id: 123 }.to_json] }
|
|
483
|
+
end
|
|
484
|
+
else
|
|
485
|
+
# Default adapter
|
|
486
|
+
conn.adapter Faraday.default_adapter
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
## Logging Configuration
|
|
495
|
+
|
|
496
|
+
### Custom Logger
|
|
497
|
+
|
|
498
|
+
```ruby
|
|
499
|
+
require 'logger'
|
|
500
|
+
|
|
501
|
+
module MyApi
|
|
502
|
+
include Resteze
|
|
503
|
+
|
|
504
|
+
class ApiLogger < Logger
|
|
505
|
+
def format_message(severity, timestamp, progname, msg)
|
|
506
|
+
{
|
|
507
|
+
severity: severity,
|
|
508
|
+
timestamp: timestamp.iso8601,
|
|
509
|
+
service: 'my_api',
|
|
510
|
+
message: msg
|
|
511
|
+
}.to_json + "\n"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
configure do |config|
|
|
516
|
+
config.logger = ApiLogger.new('log/my_api.log', 'daily')
|
|
517
|
+
config.logger.level = Rails.env.production? ? Logger::INFO : Logger::DEBUG
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### Structured Logging
|
|
523
|
+
|
|
524
|
+
```ruby
|
|
525
|
+
module MyApi
|
|
526
|
+
class Client < Resteze::Client
|
|
527
|
+
private
|
|
528
|
+
|
|
529
|
+
def log_request(context)
|
|
530
|
+
logger.info({
|
|
531
|
+
event: 'api_request',
|
|
532
|
+
method: context.method,
|
|
533
|
+
path: context.path,
|
|
534
|
+
headers: sanitize_headers(context.headers),
|
|
535
|
+
params: sanitize_params(context.query_params)
|
|
536
|
+
}.to_json)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def log_response(context, request_start, response)
|
|
540
|
+
logger.info({
|
|
541
|
+
event: 'api_response',
|
|
542
|
+
method: context.method,
|
|
543
|
+
path: context.path,
|
|
544
|
+
status: response.status,
|
|
545
|
+
duration_ms: ((Time.now - request_start) * 1000).round,
|
|
546
|
+
request_id: response.headers['x-request-id']
|
|
547
|
+
}.to_json)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def sanitize_headers(headers)
|
|
551
|
+
headers.transform_values do |value|
|
|
552
|
+
value.to_s.include?('Bearer') ? '[REDACTED]' : value
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def sanitize_params(params)
|
|
557
|
+
params.transform_values do |value|
|
|
558
|
+
sensitive_param?(value) ? '[REDACTED]' : value
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def sensitive_param?(value)
|
|
563
|
+
value.to_s.match?(/password|token|secret|key/i)
|
|
564
|
+
end
|
|
565
|
+
end
|
|
566
|
+
end
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
## Proxy Configuration
|
|
570
|
+
|
|
571
|
+
### Basic Proxy
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
module MyApi
|
|
575
|
+
include Resteze
|
|
576
|
+
|
|
577
|
+
configure do |config|
|
|
578
|
+
config.proxy = 'http://proxy.example.com:8080'
|
|
579
|
+
end
|
|
580
|
+
end
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
### Authenticated Proxy
|
|
584
|
+
|
|
585
|
+
```ruby
|
|
586
|
+
module MyApi
|
|
587
|
+
include Resteze
|
|
588
|
+
|
|
589
|
+
configure do |config|
|
|
590
|
+
config.proxy = {
|
|
591
|
+
uri: 'http://proxy.example.com:8080',
|
|
592
|
+
user: ENV['PROXY_USER'],
|
|
593
|
+
password: ENV['PROXY_PASSWORD']
|
|
594
|
+
}
|
|
595
|
+
end
|
|
596
|
+
end
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
### Conditional Proxy
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
module MyApi
|
|
603
|
+
include Resteze
|
|
604
|
+
|
|
605
|
+
configure do |config|
|
|
606
|
+
# Only use proxy in production
|
|
607
|
+
if Rails.env.production?
|
|
608
|
+
config.proxy = ENV['HTTP_PROXY']
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Or use different proxies per environment
|
|
612
|
+
config.proxy = case Rails.env
|
|
613
|
+
when 'production'
|
|
614
|
+
'http://prod-proxy.example.com:8080'
|
|
615
|
+
when 'staging'
|
|
616
|
+
'http://staging-proxy.example.com:8080'
|
|
617
|
+
else
|
|
618
|
+
nil # No proxy for development/test
|
|
619
|
+
end
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
## Rate Limits
|
|
625
|
+
|
|
626
|
+
### The RateLimiter
|
|
627
|
+
|
|
628
|
+
APIs disagree about rate limits: which headers carry the quota, whether the reset is an epoch or a countdown, whether a 429 is the only signal, whether the client should pace itself or just react. Resteze assumes none of it. `include Resteze` gives your module a `MyApi::RateLimiter`, a subclass of `Resteze::RateLimiter` whose every method is a no-op, and the client calls one instance of it, shared by every thread, at fixed points in each request:
|
|
629
|
+
|
|
630
|
+
| Called | When | Default |
|
|
631
|
+
|---|---|---|
|
|
632
|
+
| `before_request(method, path, params:, headers:)` | before sending | waits for the strategy, if one is configured |
|
|
633
|
+
| `track(response_or_error)` | after every `Response` and every `MyApi::Error` | lets the strategy learn from the API |
|
|
634
|
+
| `retry?(error, attempt)` | after a `MyApi::RateLimitError`; `attempt` starts at 1 | `false` |
|
|
635
|
+
| `retry_after(error)` | before a retry | the error's `Retry-After` |
|
|
636
|
+
| `rate_limit(response_or_error)` | when `rate_limit` is read on a response or error | the strategy's view, else `nil` |
|
|
637
|
+
|
|
638
|
+
The instance is `MyApi.rate_limiter`; assign one to replace it. Each attempt of a retried request is logged and instrumented on its own.
|
|
639
|
+
|
|
640
|
+
### Choosing a strategy
|
|
641
|
+
|
|
642
|
+
The four common algorithms are built in. Pick one with `strategy` in your module's `RateLimiter`:
|
|
643
|
+
|
|
644
|
+
```ruby
|
|
645
|
+
module Braze
|
|
646
|
+
class RateLimiter
|
|
647
|
+
strategy :fixed_window, limit: 250_000, window: 3600, headers: true
|
|
648
|
+
end
|
|
649
|
+
end
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
| Strategy | Options | Behaviour |
|
|
653
|
+
|---|---|---|
|
|
654
|
+
| `:fixed_window` | `limit:`, `window:` seconds, `headers:`, `reset:` | At most `limit` requests per window, windows aligned to the first request. With `headers: true` the budget and window follow the API's `X-RateLimit-Limit`, `-Remaining` and `-Reset` headers, which is how Braze and JD Power report theirs; pass a Hash to name other headers, and `reset: :seconds` when the reset header is a countdown rather than a time. `limit:` may be omitted when the API sends it, or sends only the remaining count. Syncing is conservative: within a window the lower of the local and reported remaining counts wins, since requests admitted after the server answered are not in its number yet; a reset beyond the local window starts a fresh one; non-finite or far-future resets are not trusted |
|
|
655
|
+
| `:sliding_window` | `limit:`, `window:` seconds | At most `limit` requests in any trailing window. Exact; memory bounded by `limit` |
|
|
656
|
+
| `:token_bucket` | `capacity:`, `refill_rate:` per second | Bursts up to `capacity`, then paced at the refill rate |
|
|
657
|
+
| `:leaky_bucket` | `capacity:`, `leak_rate:` per second | Smooths bursts into a steady rate; a full bucket waits for room |
|
|
658
|
+
|
|
659
|
+
With a strategy, `before_request` waits until the request fits, outside the lock so one thread's wait never blocks another's bookkeeping, and `rate_limit` on any response or error returns a `Resteze::RateLimit` (`limit`, `remaining`, `reset`, `exhausted?`) of the strategy's current view. For your own algorithm, subclass `Resteze::RateLimiter::Strategy` and pass the class to `strategy`; options after it go to your constructor. `acquire(state, now)` is required: record one request in `state` and return `0`, or record nothing and return the seconds until one could be admitted. `observe(state, response, now)` and `describe(state, now)` are optional. `state` is the limiter's hash, already under its lock, and the strategy itself must stay stateless. A daily quota aligned to midnight UTC:
|
|
660
|
+
|
|
661
|
+
```ruby
|
|
662
|
+
module MyApi
|
|
663
|
+
class DailyQuota < Resteze::RateLimiter::Strategy
|
|
664
|
+
DAY = 86_400
|
|
665
|
+
|
|
666
|
+
def initialize(limit:)
|
|
667
|
+
super()
|
|
668
|
+
@limit = limit
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
def acquire(state, now)
|
|
672
|
+
roll(state, now)
|
|
673
|
+
return (state[:day_started] + DAY) - now if state[:count] >= @limit
|
|
674
|
+
|
|
675
|
+
state[:count] += 1
|
|
676
|
+
0
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def observe(state, response, now)
|
|
680
|
+
data = response.respond_to?(:data) ? response.data : response.json_body
|
|
681
|
+
return unless data.is_a?(Hash) && data[:calls_remaining]
|
|
682
|
+
|
|
683
|
+
roll(state, now)
|
|
684
|
+
state[:count] = @limit - data[:calls_remaining].to_i
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
def describe(state, now)
|
|
688
|
+
roll(state, now)
|
|
689
|
+
Resteze::RateLimit.new(limit: @limit, remaining: @limit - state[:count], reset: state[:day_started] + DAY)
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
private
|
|
693
|
+
|
|
694
|
+
def roll(state, now)
|
|
695
|
+
today = now - (now % DAY)
|
|
696
|
+
return if state[:day_started] == today
|
|
697
|
+
|
|
698
|
+
state[:day_started] = today
|
|
699
|
+
state[:count] = 0
|
|
700
|
+
end
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
class RateLimiter
|
|
704
|
+
strategy DailyQuota, limit: 10_000
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
### Reacting to a 429
|
|
710
|
+
|
|
711
|
+
A strategy keeps you under the limit; `retry?` and `retry_after` say what to do when the API disagrees. Override them alongside, or instead of, a strategy:
|
|
712
|
+
|
|
713
|
+
```ruby
|
|
714
|
+
module Braze
|
|
715
|
+
class RateLimiter
|
|
716
|
+
strategy :fixed_window, limit: 250_000, window: 3600, headers: true
|
|
717
|
+
|
|
718
|
+
def retry?(_error, attempt)
|
|
719
|
+
attempt <= 3
|
|
720
|
+
end
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
`retry_after` reads the HTTP `Retry-After` header, accepting delay-seconds or an HTTP-date. For an API that signals the wait another way, override it; `header` does a case-insensitive lookup on the error:
|
|
726
|
+
|
|
727
|
+
```ruby
|
|
728
|
+
def retry_after(error)
|
|
729
|
+
error.header('X-Retry-In')&.to_i || super
|
|
730
|
+
end
|
|
731
|
+
```
|
|
732
|
+
|
|
733
|
+
### Waiting without blocking
|
|
734
|
+
|
|
735
|
+
With a strategy, `before_request` waits in the calling thread by default (`on_wait: :sleep`). A background job would often rather give the thread back and reschedule itself. `on_wait: :raise` makes the limiter raise `MyApi::RateLimitError` instead of waiting, with the computed wait in `retry_after` and no `http_status`, since nothing was sent. An error raised this way never goes through `retry?`; it is a refusal, not a failed attempt.
|
|
736
|
+
|
|
737
|
+
```ruby
|
|
738
|
+
module Braze
|
|
739
|
+
class RateLimiter
|
|
740
|
+
strategy :fixed_window, limit: 250_000, window: 3600, headers: true, on_wait: :raise
|
|
741
|
+
end
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
class TrackEventsJob
|
|
745
|
+
include Sidekiq::Job
|
|
746
|
+
|
|
747
|
+
sidekiq_retry_in do |_count, exception|
|
|
748
|
+
exception.respond_to?(:retry_after) ? (exception.retry_after || 60) : :default
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def perform(events)
|
|
752
|
+
Braze::User.track(events:) # a spent budget, or a 429, raises; Sidekiq reschedules
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
The same option is accepted by the constructor, `MyApi::RateLimiter.new(on_wait: :raise)`, and `RateLimitError.new(message, retry_after: seconds)` builds such an error by hand. Under `on_wait: :raise` a retry decided by `retry?` is not slept either: the 429 itself is raised, with its `retry_after`, so both cases reach the job the same way.
|
|
758
|
+
|
|
759
|
+
Every wait, whether from a strategy or before a retry, is clamped to `max_wait` seconds, one hour by default. A `Retry-After` or reset header that is absurd, non-finite or malicious therefore costs at most one `max_wait`, never a thread parked for a year or a crash on `sleep(Infinity)`. Set it alongside `on_wait:` in `strategy`, or in the constructor:
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
strategy :fixed_window, limit: 1000, window: 60, max_wait: 120
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
### Overriding the lifecycle
|
|
766
|
+
|
|
767
|
+
Any method can be overridden; call `super` to keep the strategy's behaviour. State belongs in the limiter's mutex-guarded hash, never in instance variables, so a subclass stays thread-safe without doing anything: `state` returns a snapshot, `update_state` merges values, and `synchronize { |state| ... }` yields the live hash for read-modify-write sequences.
|
|
768
|
+
|
|
769
|
+
```ruby
|
|
770
|
+
module Braze
|
|
771
|
+
class RateLimiter
|
|
772
|
+
strategy :fixed_window, limit: 250_000, window: 3600, headers: true
|
|
773
|
+
|
|
774
|
+
def before_request(method, path, **)
|
|
775
|
+
raise RateLimitError, "budget reserved for exports" if path.start_with?("/users/export") && state[:remaining].to_i < 1000
|
|
776
|
+
|
|
777
|
+
super
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
def track(response)
|
|
781
|
+
super
|
|
782
|
+
update_state(remaining: response.rate_limit&.remaining)
|
|
783
|
+
end
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
`Resteze::RateLimit.from_headers(headers, names = {})` remains available as a helper for reading the three-header shape by hand.
|
|
789
|
+
|
|
790
|
+
### Retrying after a 429
|
|
791
|
+
|
|
792
|
+
Resteze retries a 429 only when your `RateLimiter`'s `retry?` says so. For retries handled below the limiter, with backoff and per-status rules, add the [`faraday-retry`](https://github.com/lostisland/faraday-retry) gem (it is not a Resteze dependency) and place it **after** `RaiseError` in the stack. That puts it closer to the adapter, so it sees the raw 429 and retries before `RaiseError` turns the final answer into an error. `retry_statuses: [429]` makes it honor `Retry-After` automatically.
|
|
793
|
+
|
|
794
|
+
```ruby
|
|
795
|
+
require 'faraday/retry'
|
|
796
|
+
|
|
797
|
+
module MyApi
|
|
798
|
+
class Client < Resteze::Client
|
|
799
|
+
def self.default_connection
|
|
800
|
+
Thread.current["#{client_name}_default_connection"] ||= Faraday.new do |conn|
|
|
801
|
+
conn.use Faraday::Request::UrlEncoded
|
|
802
|
+
conn.use Middleware::RaiseError
|
|
803
|
+
conn.request :retry,
|
|
804
|
+
max: 3,
|
|
805
|
+
interval: 0.5,
|
|
806
|
+
backoff_factor: 2,
|
|
807
|
+
retry_statuses: [429],
|
|
808
|
+
methods: %i[get post] # POST is not retried by default; add it only
|
|
809
|
+
# where the API treats a 429 as "not processed"
|
|
810
|
+
conn.adapter Faraday.default_adapter
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
For an API that signals the wait through a non-standard header, pass `rate_limit_retry_header:` and `rate_limit_reset_header:`, with a `header_parser_block:` to convert the value into seconds. See the faraday-retry README.
|
|
818
|
+
|
|
819
|
+
## Advanced Configuration Patterns
|
|
820
|
+
|
|
821
|
+
### Configuration with Validation
|
|
822
|
+
|
|
823
|
+
```ruby
|
|
824
|
+
module MyApi
|
|
825
|
+
include Resteze
|
|
826
|
+
|
|
827
|
+
class Configuration
|
|
828
|
+
REQUIRED_SETTINGS = %i[api_base api_key].freeze
|
|
829
|
+
VALID_ENVIRONMENTS = %w[production staging development test].freeze
|
|
830
|
+
|
|
831
|
+
def self.validate!(config)
|
|
832
|
+
REQUIRED_SETTINGS.each do |setting|
|
|
833
|
+
value = config.send(setting)
|
|
834
|
+
if value.nil? || value.to_s.empty?
|
|
835
|
+
raise "Configuration error: #{setting} is required"
|
|
836
|
+
end
|
|
837
|
+
end
|
|
838
|
+
|
|
839
|
+
# Validate API base URL
|
|
840
|
+
unless config.api_base =~ URI::DEFAULT_PARSER.make_regexp
|
|
841
|
+
raise "Configuration error: api_base must be a valid URL"
|
|
842
|
+
end
|
|
843
|
+
|
|
844
|
+
# Validate environment
|
|
845
|
+
if config.respond_to?(:environment)
|
|
846
|
+
unless VALID_ENVIRONMENTS.include?(config.environment)
|
|
847
|
+
raise "Configuration error: invalid environment '#{config.environment}'"
|
|
848
|
+
end
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Validate timeouts
|
|
852
|
+
if config.open_timeout <= 0 || config.read_timeout <= 0
|
|
853
|
+
raise "Configuration error: timeouts must be positive"
|
|
854
|
+
end
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
configure do |config|
|
|
859
|
+
config.api_base = ENV['MY_API_URL']
|
|
860
|
+
config.api_key = ENV['MY_API_KEY']
|
|
861
|
+
config.open_timeout = 30
|
|
862
|
+
config.read_timeout = 60
|
|
863
|
+
|
|
864
|
+
# Validate configuration
|
|
865
|
+
Configuration.validate!(config)
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
### Feature Flags Configuration
|
|
871
|
+
|
|
872
|
+
```ruby
|
|
873
|
+
module MyApi
|
|
874
|
+
include Resteze
|
|
875
|
+
|
|
876
|
+
class << self
|
|
877
|
+
attr_accessor :features
|
|
878
|
+
|
|
879
|
+
def feature_enabled?(feature)
|
|
880
|
+
features && features[feature] == true
|
|
881
|
+
end
|
|
882
|
+
end
|
|
883
|
+
|
|
884
|
+
configure do |config|
|
|
885
|
+
config.api_base = 'https://api.example.com/'
|
|
886
|
+
|
|
887
|
+
# Feature flags
|
|
888
|
+
config.features = {
|
|
889
|
+
caching: Rails.env.production?,
|
|
890
|
+
retry: true,
|
|
891
|
+
circuit_breaker: Rails.env.production?,
|
|
892
|
+
detailed_logging: Rails.env.development?,
|
|
893
|
+
batch_requests: true
|
|
894
|
+
}
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
class Client < Resteze::Client
|
|
898
|
+
def execute_request(method, path, **options)
|
|
899
|
+
# Use feature flags
|
|
900
|
+
if api_module.feature_enabled?(:caching) && method == :get
|
|
901
|
+
cached_request(method, path, **options)
|
|
902
|
+
else
|
|
903
|
+
super
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
private
|
|
908
|
+
|
|
909
|
+
def cached_request(method, path, **options)
|
|
910
|
+
cache_key = "#{path}:#{options.hash}"
|
|
911
|
+
Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
|
|
912
|
+
super(method, path, **options)
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
### Configuration Registry
|
|
920
|
+
|
|
921
|
+
```ruby
|
|
922
|
+
module MyApi
|
|
923
|
+
include Resteze
|
|
924
|
+
|
|
925
|
+
class ConfigurationRegistry
|
|
926
|
+
def self.configurations
|
|
927
|
+
@configurations ||= {}
|
|
928
|
+
end
|
|
929
|
+
|
|
930
|
+
def self.register(name, &block)
|
|
931
|
+
configurations[name] = block
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
def self.apply(name)
|
|
935
|
+
config_block = configurations[name]
|
|
936
|
+
raise "Unknown configuration: #{name}" unless config_block
|
|
937
|
+
|
|
938
|
+
MyApi.configure(&config_block)
|
|
939
|
+
end
|
|
940
|
+
end
|
|
941
|
+
|
|
942
|
+
# Register configurations
|
|
943
|
+
ConfigurationRegistry.register(:production) do |config|
|
|
944
|
+
config.api_base = 'https://api.example.com/'
|
|
945
|
+
config.api_key = ENV['PROD_API_KEY']
|
|
946
|
+
config.open_timeout = 30
|
|
947
|
+
config.read_timeout = 60
|
|
948
|
+
end
|
|
949
|
+
|
|
950
|
+
ConfigurationRegistry.register(:development) do |config|
|
|
951
|
+
config.api_base = 'http://localhost:3000/'
|
|
952
|
+
config.api_key = 'dev-key'
|
|
953
|
+
config.open_timeout = 60
|
|
954
|
+
config.read_timeout = 120
|
|
955
|
+
config.logger = Logger.new($stdout)
|
|
956
|
+
config.logger.level = Logger::DEBUG
|
|
957
|
+
end
|
|
958
|
+
|
|
959
|
+
# Apply configuration
|
|
960
|
+
ConfigurationRegistry.apply(Rails.env.to_sym)
|
|
961
|
+
end
|
|
962
|
+
```
|