rest-easy 1.0.0 → 1.1.2
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/CHANGELOG.md +55 -0
- data/README.md +866 -0
- data/lib/rest_easy/conventions.rb +2 -0
- data/lib/rest_easy/resource.rb +34 -11
- data/lib/rest_easy/settings.rb +6 -1
- data/lib/rest_easy/version.rb +1 -1
- data/lib/rest_easy.rb +11 -1
- metadata +23 -32
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ec1e2b4f51ad07119add9f89dd2075a765fa2fc258766294aa7b05c1bc4e2951
|
|
4
|
+
data.tar.gz: 350b2381fd3e0dd4cde1815000684c474238ed5c5f8d438611a8fb5de9e381b8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 358c1e91aca70417fc86738b71078bf931da0a562f3ea102875e25d6c32e117c0663337770b39bfc0bf978022dec0eafea220cf27c83e86f9f63f5c3038f6d99
|
|
7
|
+
data.tar.gz: 0b6d24c285a668c5dde3a90168d542c60b7b8008f20a75d39579c058448e8dd31482f23bda5fa770c25909736b43804cd03a591f7813345a0aabeccc75ad7b26
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.1.2] - 2026-05-15
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **`dry-configurable` requirement bumped from `~> 0.14` to `~> 1.0`.** The 0.14 pin blocked any downstream that pulled in `dry-configurable >= 1.0`.
|
|
10
|
+
|
|
11
|
+
## [1.1.1] - 2026-05-15
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **`conversions.query_parameters` default changed from `:PascalCase` to `nil`.** In 1.1.0 the new automatic query parameter transformation combined with a `:PascalCase` default silently rewrote keys for every consumer, regardless of intent — `Resource.get(params: { city: "X" })` produced `?City=X` instead of `?city=X`. With the new default, `Resource.get` does not transform parameter keys unless `conversions.query_parameters` is explicitly configured, restoring 1.0.0 behaviour. The `json_attributes` default remains `:PascalCase`, since that preserves 1.0.0's `attribute_convention` default.
|
|
16
|
+
|
|
17
|
+
## [1.1.0] - 2026-05-15
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`conversions` configuration** with independent `query_parameters` and `json_attributes` sub-keys. This allows APIs that use different naming conventions for query parameters vs JSON body attributes to be configured correctly:
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
module MyAPI
|
|
25
|
+
extend RestEasy
|
|
26
|
+
|
|
27
|
+
configure do
|
|
28
|
+
conversions.json_attributes = :camelCase
|
|
29
|
+
conversions.query_parameters = :PascalCase
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- **Automatic query parameter key transformation.** `Resource.get` now transforms parameter keys according to the `query_parameters` convention before sending the request. This removes the need for manual `transform_keys` calls in consuming gems.
|
|
35
|
+
|
|
36
|
+
- `conversions` can be overridden per Resource class, with inheritance falling back to the parent API module configuration.
|
|
37
|
+
|
|
38
|
+
### Deprecated
|
|
39
|
+
|
|
40
|
+
- **`attribute_convention`** is deprecated in favour of `conversions.json_attributes`. The old setting continues to work — it is propagated to `conversions.json_attributes` at the module level and respected as a fallback at the resource level — but emits a deprecation warning in both cases.
|
|
41
|
+
|
|
42
|
+
### Removed
|
|
43
|
+
|
|
44
|
+
- **`dry-inflector` runtime dependency.** The gem never used `Dry::Inflector` — Zeitwerk's own inflector is the only one used.
|
|
45
|
+
- **Default value for `attribute_convention`.** Previously defaulted to `:PascalCase`. The setting is now unset by default; reading `MyAPI::Settings.config.attribute_convention` directly returns `nil` unless explicitly configured. The effective default for naming conversion now lives on `conversions.json_attributes` (also `:PascalCase`).
|
|
46
|
+
|
|
47
|
+
## [1.0.0] - 2026-03-19
|
|
48
|
+
|
|
49
|
+
Initial release.
|
|
50
|
+
|
|
51
|
+
[Unreleased]: https://github.com/accodeing/rest-easy/compare/v1.1.2...HEAD
|
|
52
|
+
[1.1.2]: https://github.com/accodeing/rest-easy/compare/v1.1.1...v1.1.2
|
|
53
|
+
[1.1.1]: https://github.com/accodeing/rest-easy/compare/v1.1.0...v1.1.1
|
|
54
|
+
[1.1.0]: https://github.com/accodeing/rest-easy/compare/v1.0.0...v1.1.0
|
|
55
|
+
[1.0.0]: https://github.com/accodeing/rest-easy/releases/tag/v1.0.0
|
data/README.md
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
# RestEasy
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/rest-easy)
|
|
4
|
+
|
|
5
|
+
A Ruby framework for building REST API client libraries. Define your resources with a clean DSL, and RestEasy handles naming conventions, type coercion, serialisation, authentication, and HTTP plumbing — so you can ship an API gem with minimal boilerplate.
|
|
6
|
+
|
|
7
|
+
Built on [dry-rb](https://dry-rb.org/) (Types, Configurable) and [Faraday](https://lostisland.github.io/faraday/).
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
Add to your gemspec:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
spec.add_runtime_dependency "rest-easy", "~> 1.0"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Or your Gemfile:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem "rest-easy", "~> 1.0"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Requires Ruby >= 3.1.
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
A complete API client in three steps:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
# 1. Define your API module
|
|
31
|
+
require "rest_easy"
|
|
32
|
+
|
|
33
|
+
module Acme
|
|
34
|
+
extend RestEasy
|
|
35
|
+
|
|
36
|
+
configure do
|
|
37
|
+
base_url "https://api.acme.com/v1"
|
|
38
|
+
authentication RestEasy::Auth::PSK.new(api_key: ENV["ACME_API_KEY"])
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# 2. Define a resource
|
|
43
|
+
class Acme::Widget < RestEasy::Resource
|
|
44
|
+
configure do
|
|
45
|
+
path "widgets"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
key :id, Integer, :read_only
|
|
49
|
+
attr :name, String, :required
|
|
50
|
+
attr :price, Float
|
|
51
|
+
attr :active, Boolean
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# 3. Use it
|
|
55
|
+
widget = Acme::Widget.find(42)
|
|
56
|
+
widget.name # => "Sprocket"
|
|
57
|
+
widget.price # => 19.99
|
|
58
|
+
|
|
59
|
+
updated = widget.update(price: 24.99)
|
|
60
|
+
Acme::Widget.save(updated)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Architecture
|
|
64
|
+
|
|
65
|
+
RestEasy uses a three-layer inheritance pattern:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
RestEasy::Resource # Framework base class
|
|
69
|
+
└── YourAPI::Resource # API-level base — shared config, hooks, custom settings
|
|
70
|
+
├── YourAPI::Invoice
|
|
71
|
+
├── YourAPI::Customer
|
|
72
|
+
└── YourAPI::Article
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The API module (`YourAPI`) owns the HTTP connection, authentication, and global settings. Resources define attributes and delegate HTTP calls up to their parent module.
|
|
76
|
+
|
|
77
|
+
## Setting up your API module
|
|
78
|
+
|
|
79
|
+
Extend any module with `RestEasy` to turn it into an API container:
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
module Fortnox
|
|
83
|
+
extend RestEasy
|
|
84
|
+
|
|
85
|
+
configure do
|
|
86
|
+
base_url "https://api.fortnox.se/3"
|
|
87
|
+
max_retries 3
|
|
88
|
+
authentication RestEasy::Auth::PSK.new(api_key: ENV["FORTNOX_KEY"])
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Available settings
|
|
94
|
+
|
|
95
|
+
| Setting | Default | Description |
|
|
96
|
+
|----------------------------------|----------------------------|---------------------------------------------------|
|
|
97
|
+
| `base_url` | `"https://example.com"` | Base URL for all requests |
|
|
98
|
+
| `max_retries` | `3` | Retry count on request failure |
|
|
99
|
+
| `authentication` | `Auth::Null.new` | Authentication strategy |
|
|
100
|
+
| `conversions.json_attributes` | `:PascalCase` | Naming convention for JSON response/request fields|
|
|
101
|
+
| `conversions.query_parameters` | `nil` (no transformation) | Naming convention for query parameter keys |
|
|
102
|
+
|
|
103
|
+
### Faraday middleware
|
|
104
|
+
|
|
105
|
+
Configure the underlying Faraday connection with a `connection` block:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
module Acme
|
|
109
|
+
extend RestEasy
|
|
110
|
+
|
|
111
|
+
connection do |f|
|
|
112
|
+
f.ssl[:client_cert] = OpenSSL::X509::Certificate.new(File.read("client.crt"))
|
|
113
|
+
f.ssl[:client_key] = OpenSSL::PKey::RSA.new(File.read("client.key"))
|
|
114
|
+
f.ssl[:ca_file] = "ca.crt"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Defining resources
|
|
120
|
+
|
|
121
|
+
### The base resource
|
|
122
|
+
|
|
123
|
+
For most APIs you'll want an intermediate base class that handles API-wide patterns like response envelopes, pagination metadata, or partial response detection:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
class Fortnox::Resource < RestEasy::Resource
|
|
127
|
+
# Add custom settings for all resources in this API
|
|
128
|
+
settings do
|
|
129
|
+
setting :instance_wrapper, reader: true
|
|
130
|
+
setting :collection_wrapper, reader: true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Unwrap the response envelope before parsing
|
|
134
|
+
before_parse do |data, meta|
|
|
135
|
+
if data.key?("MetaInformation")
|
|
136
|
+
meta.total_resources = data["MetaInformation"]["@TotalResources"]
|
|
137
|
+
meta.pages = data["MetaInformation"]["@TotalPages"]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
if data.key?(config.instance_wrapper)
|
|
141
|
+
next data[config.instance_wrapper]
|
|
142
|
+
elsif data.key?(config.collection_wrapper)
|
|
143
|
+
next data[config.collection_wrapper]
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Wrap the request body in the envelope
|
|
148
|
+
after_serialise do |data|
|
|
149
|
+
{ config.instance_wrapper => data }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Concrete resources
|
|
155
|
+
|
|
156
|
+
Each resource configures its path and declares its attributes:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
class Fortnox::Article < Fortnox::Resource
|
|
160
|
+
configure do
|
|
161
|
+
path "articles"
|
|
162
|
+
instance_wrapper "Article"
|
|
163
|
+
collection_wrapper "Articles"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
key :article_number, String
|
|
167
|
+
attr :description, String, :required
|
|
168
|
+
attr :purchase_price, Float
|
|
169
|
+
attr :quantity_in_stock, Float
|
|
170
|
+
attr :sales_price, Float, :read_only
|
|
171
|
+
attr :active, Boolean
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Attributes
|
|
176
|
+
|
|
177
|
+
### Basic declaration
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
attr :name, String
|
|
181
|
+
attr :count, Integer
|
|
182
|
+
attr :price, Float
|
|
183
|
+
attr :active, Boolean
|
|
184
|
+
attr :created_at, Date
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Bare Ruby types (`String`, `Integer`, `Float`) are automatically mapped to their Dry::Types coercible equivalents. You also get `Boolean` and `Date` out of the box.
|
|
188
|
+
|
|
189
|
+
The full `Dry::Types` vocabulary is available inside resource bodies — `Strict::String`, `Coercible::Integer`, `Params::Date`, etc.
|
|
190
|
+
|
|
191
|
+
### Naming conventions
|
|
192
|
+
|
|
193
|
+
RestEasy automatically maps between Ruby's `snake_case` attribute names and the API's naming convention. The `conversions` config controls this independently for JSON attributes and query parameters. `json_attributes` defaults to `:PascalCase`; `query_parameters` defaults to `nil`, meaning keys are passed through untransformed unless you explicitly configure a convention.
|
|
194
|
+
|
|
195
|
+
| Convention | Ruby attr | API field |
|
|
196
|
+
|---------------|--------------------|----------------------|
|
|
197
|
+
| `:PascalCase` | `:document_number` | `"DocumentNumber"` |
|
|
198
|
+
| `:camelCase` | `:document_number` | `"documentNumber"` |
|
|
199
|
+
| `:snake_case` | `:document_number` | `"document_number"` |
|
|
200
|
+
|
|
201
|
+
Set conventions at the module level (applies to all resources):
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
configure do
|
|
205
|
+
conversions.json_attributes = :camelCase
|
|
206
|
+
conversions.query_parameters = :PascalCase
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Or override per resource:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
class MyAPI::Special < MyAPI::Resource
|
|
214
|
+
configure do
|
|
215
|
+
conversions.json_attributes = :PascalCase
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Query parameter keys are transformed when calling `get` with `params:` only if `conversions.query_parameters` is configured. For example, with `query_parameters: :PascalCase`, `params: { sort_order: "asc" }` becomes `?SortOrder=asc` in the request. With the default `nil`, keys pass through unchanged.
|
|
221
|
+
|
|
222
|
+
You can also provide a custom convention object with `parse(api_name)` and `serialise(model_name)` methods.
|
|
223
|
+
|
|
224
|
+
### Explicit name mapping
|
|
225
|
+
|
|
226
|
+
When the API field name doesn't follow the convention, map it explicitly. In both forms the order is always model name first, API name second — `model_name <=> 'ApiName'` or `[:model_name, 'ApiName']`.
|
|
227
|
+
|
|
228
|
+
Using the `<=>` refinement:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
using RestEasy::Refinements
|
|
232
|
+
|
|
233
|
+
attr :tax_url <=> '@urlTaxReductionList', String, :read_only
|
|
234
|
+
attr :ean <=> 'EAN', String
|
|
235
|
+
attr :eu_account <=> 'EUAccount', Integer
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Or use the array form without refinements:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
attr [:tax_url, '@urlTaxReductionList'], String, :read_only
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Flags
|
|
245
|
+
|
|
246
|
+
| Flag | Effect |
|
|
247
|
+
|--------------|----------------------------------------------------------|
|
|
248
|
+
| `:required` | Raises `MissingAttributeError` if absent in API response |
|
|
249
|
+
| `:optional` | Documents that the field may be absent (default) |
|
|
250
|
+
| `:read_only` | Excluded from serialisation (not sent back to the API) |
|
|
251
|
+
| `:key` | Marks the unique identifier for CRUD operations |
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
key :id, Integer, :read_only
|
|
255
|
+
attr :name, String, :required
|
|
256
|
+
attr :created_at, Date, :read_only
|
|
257
|
+
attr :nickname, String, :optional
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
The `key` method is shorthand for `attr` with the `:key` flag.
|
|
261
|
+
|
|
262
|
+
Beyond the built-in flags, you can use any symbol as a custom flag. Custom flags have no automatic behaviour — they're metadata you can query with `attributes_with_flag` and act on in hooks or query methods:
|
|
263
|
+
|
|
264
|
+
```ruby
|
|
265
|
+
class MyAPI::Invoice < MyAPI::Resource
|
|
266
|
+
attr :internal_notes, String, :never_send_to_api
|
|
267
|
+
attr :debug_info, String, :never_send_to_api
|
|
268
|
+
attr :customer_name, String
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
class MyAPI::Resource < RestEasy::Resource
|
|
272
|
+
after_serialise do |data|
|
|
273
|
+
blocked = self.class.attributes_with_flag(:never_send_to_api).values.map(&:api_name)
|
|
274
|
+
blocked.each { |key| data.delete(key) }
|
|
275
|
+
data
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### Type constraints
|
|
281
|
+
|
|
282
|
+
Use Dry::Types constraints for validation:
|
|
283
|
+
|
|
284
|
+
```ruby
|
|
285
|
+
attr :name, String.constrained(max_size: 100)
|
|
286
|
+
attr :age, Integer.constrained(gteq: 0)
|
|
287
|
+
attr :status, Types::Strict::String.enum("active", "inactive")
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
Constraint violations raise `RestEasy::ConstraintError`.
|
|
291
|
+
|
|
292
|
+
### Custom parse and serialise
|
|
293
|
+
|
|
294
|
+
Transform values during parsing (API to model) and serialisation (model to API):
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
attr :status, String do
|
|
298
|
+
parse { |raw| raw.strip.downcase }
|
|
299
|
+
serialise { |val| val.upcase }
|
|
300
|
+
end
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
### Mapper objects
|
|
304
|
+
|
|
305
|
+
Extract parse/serialise logic into reusable objects. Any object that responds to `.parse` and `.serialise` works:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
module DateMapper
|
|
309
|
+
def self.parse(value)
|
|
310
|
+
Date.parse(value)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def self.serialise(value)
|
|
314
|
+
value.strftime("%F")
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
attr :invoice_date, Date, DateMapper
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Merge pattern — many API fields into one model attribute
|
|
322
|
+
|
|
323
|
+
When the parse method takes multiple parameters, RestEasy automatically extracts the corresponding API fields and passes them in:
|
|
324
|
+
|
|
325
|
+
```ruby
|
|
326
|
+
attr :full_name, String do
|
|
327
|
+
parse { |first_name, last_name| "#{first_name} #{last_name}" }
|
|
328
|
+
serialise { |full_name| full_name.split(" ", 2) }
|
|
329
|
+
end
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The parameter names (`first_name`, `last_name`) are resolved through the naming convention to find the API fields (`FirstName`, `LastName`). On serialisation, the array return value is zipped back to those field names.
|
|
333
|
+
|
|
334
|
+
This also works with mapper objects:
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
module FullNameMapper
|
|
338
|
+
def self.parse(first_name, last_name)
|
|
339
|
+
"#{first_name} #{last_name}"
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def self.serialise(full_name)
|
|
343
|
+
full_name.split(" ", 2)
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
attr :full_name, String, FullNameMapper
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
### Split pattern — one API field into many model attributes
|
|
351
|
+
|
|
352
|
+
Use a bare block with a parameter to extract from a single API field:
|
|
353
|
+
|
|
354
|
+
```ruby
|
|
355
|
+
attr :street, String do |address|
|
|
356
|
+
address["street"]
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
attr :city, String do |address|
|
|
360
|
+
address["city"]
|
|
361
|
+
end
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
The parameter name (`address`) determines which API field to read from.
|
|
365
|
+
|
|
366
|
+
### Ignoring fields
|
|
367
|
+
|
|
368
|
+
Tell RestEasy to silently skip API fields you don't need:
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
ignore :internal_id, :legacy_code
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
With `debug: true` in your resource config, RestEasy warns about undeclared API fields. Use `ignore` to silence those warnings for fields you intentionally skip.
|
|
375
|
+
|
|
376
|
+
## Hooks
|
|
377
|
+
|
|
378
|
+
Hooks let you transform data at specific points in the parse and serialise lifecycle.
|
|
379
|
+
|
|
380
|
+
### `before_parse`
|
|
381
|
+
|
|
382
|
+
Runs before attribute parsing. Receives the raw API data hash and a meta collector. The return value replaces the data for parsing.
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
before_parse do |data, meta|
|
|
386
|
+
meta.response_code = data.delete("responseCode")
|
|
387
|
+
next data["result"]
|
|
388
|
+
end
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
When the return value is an `Array`, RestEasy parses each item and returns an array of instances.
|
|
392
|
+
|
|
393
|
+
### `after_parse`
|
|
394
|
+
|
|
395
|
+
Runs after all attributes have been parsed. Access `model`, `api`, and `meta` on the instance. Return value is ignored.
|
|
396
|
+
|
|
397
|
+
```ruby
|
|
398
|
+
after_parse do
|
|
399
|
+
meta.partial = api.attributes.length < model.attributes.length
|
|
400
|
+
end
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### `before_serialise`
|
|
404
|
+
|
|
405
|
+
Runs before serialisation. Receives the model attributes hash. Return value is ignored (side-effects only).
|
|
406
|
+
|
|
407
|
+
```ruby
|
|
408
|
+
before_serialise do |attrs|
|
|
409
|
+
raise "Name required" unless attrs[:name]
|
|
410
|
+
end
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### `after_serialise`
|
|
414
|
+
|
|
415
|
+
Runs after serialisation. Receives the serialised hash. The return value becomes the final output.
|
|
416
|
+
|
|
417
|
+
```ruby
|
|
418
|
+
after_serialise do |data|
|
|
419
|
+
{ "Invoice" => data }
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
### Hook inheritance
|
|
424
|
+
|
|
425
|
+
Hooks resolve up the ancestor chain. A hook defined on `Fortnox::Resource` applies to all Fortnox resources. Override a hook in a child class to replace (not append to) the parent's hook.
|
|
426
|
+
|
|
427
|
+
If you want to extend rather than fully replace a parent hook, call the parent's hook explicitly via `superclass`:
|
|
428
|
+
|
|
429
|
+
```ruby
|
|
430
|
+
class Fortnox::Invoice < Fortnox::Resource
|
|
431
|
+
before_parse do |data, meta|
|
|
432
|
+
# Run the parent's before_parse first (envelope unwrapping, etc.)
|
|
433
|
+
data = instance_exec(data, meta, &superclass.resolve_before_parse_hook)
|
|
434
|
+
|
|
435
|
+
# Then do invoice-specific transforms
|
|
436
|
+
data.delete("InternalFields")
|
|
437
|
+
next data
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
## Meta
|
|
443
|
+
|
|
444
|
+
Every instance carries a `meta` object for tracking state and custom metadata:
|
|
445
|
+
|
|
446
|
+
```ruby
|
|
447
|
+
widget = Acme::Widget.find(42)
|
|
448
|
+
widget.meta.new? # => false (came from API)
|
|
449
|
+
widget.meta.saved? # => true (persisted)
|
|
450
|
+
|
|
451
|
+
draft = Acme::Widget.stub(name: "Draft")
|
|
452
|
+
draft.meta.new? # => true (created locally)
|
|
453
|
+
draft.meta.saved? # => false (not persisted)
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Custom metadata
|
|
457
|
+
|
|
458
|
+
Set and query arbitrary metadata — useful in hooks:
|
|
459
|
+
|
|
460
|
+
```ruby
|
|
461
|
+
before_parse do |data, meta|
|
|
462
|
+
meta.total_pages = data["MetaInformation"]["@TotalPages"]
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Later:
|
|
466
|
+
result = Fortnox::Invoice.all
|
|
467
|
+
result.first.meta.total_pages # => 5
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
### Metadata defaults
|
|
471
|
+
|
|
472
|
+
Declare defaults at the class level:
|
|
473
|
+
|
|
474
|
+
```ruby
|
|
475
|
+
class Fortnox::Resource < RestEasy::Resource
|
|
476
|
+
metadata partial: false
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
instance.meta.partial? # => false (default)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
Defaults are inherited and merged down the class hierarchy.
|
|
483
|
+
|
|
484
|
+
## Authentication
|
|
485
|
+
|
|
486
|
+
RestEasy ships with three auth strategies:
|
|
487
|
+
|
|
488
|
+
### Null (default)
|
|
489
|
+
|
|
490
|
+
No authentication. Use when auth is handled at the transport level (mTLS, VPN, etc.):
|
|
491
|
+
|
|
492
|
+
```ruby
|
|
493
|
+
authentication RestEasy::Auth::Null.new
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### PSK (Pre-Shared Key / API Key)
|
|
497
|
+
|
|
498
|
+
Static API key sent as a header:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
authentication RestEasy::Auth::PSK.new(
|
|
502
|
+
api_key: ENV["API_KEY"],
|
|
503
|
+
header_name: "Authorization", # default
|
|
504
|
+
header_prefix: "Bearer" # default
|
|
505
|
+
)
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
### Basic
|
|
509
|
+
|
|
510
|
+
HTTP Basic authentication:
|
|
511
|
+
|
|
512
|
+
```ruby
|
|
513
|
+
authentication RestEasy::Auth::Basic.new(
|
|
514
|
+
username: ENV["API_USER"],
|
|
515
|
+
password: ENV["API_PASS"]
|
|
516
|
+
)
|
|
517
|
+
```
|
|
518
|
+
|
|
519
|
+
### Custom authentication
|
|
520
|
+
|
|
521
|
+
Implement `apply(request)` and `on_rejected(response)`:
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
class OAuth2Auth
|
|
525
|
+
def apply(request)
|
|
526
|
+
refresh_token! if expired?
|
|
527
|
+
request.headers["Authorization"] = "Bearer #{@access_token}"
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
def on_rejected(response)
|
|
531
|
+
# Returning normally triggers a retry (up to max_retries).
|
|
532
|
+
# Raising propagates the error immediately.
|
|
533
|
+
refresh_token!
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
The retry lifecycle:
|
|
539
|
+
|
|
540
|
+
1. `auth.apply(request)` — attach credentials
|
|
541
|
+
2. Make HTTP request
|
|
542
|
+
3. On failure: `auth.on_rejected(response)`
|
|
543
|
+
- Return normally → retry (up to `max_retries`)
|
|
544
|
+
- Raise → propagate error
|
|
545
|
+
|
|
546
|
+
## CRUD operations
|
|
547
|
+
|
|
548
|
+
Resources provide standard CRUD methods:
|
|
549
|
+
|
|
550
|
+
```ruby
|
|
551
|
+
# Fetch
|
|
552
|
+
invoice = Fortnox::Invoice.find(123)
|
|
553
|
+
invoices = Fortnox::Invoice.all
|
|
554
|
+
|
|
555
|
+
# Create
|
|
556
|
+
draft = Fortnox::Invoice.stub(customer_name: "Acme", amount: 500.0)
|
|
557
|
+
created = Fortnox::Invoice.create(draft)
|
|
558
|
+
|
|
559
|
+
# Update
|
|
560
|
+
updated = invoice.update(amount: 750.0)
|
|
561
|
+
saved = Fortnox::Invoice.save(updated)
|
|
562
|
+
|
|
563
|
+
# Delete
|
|
564
|
+
Fortnox::Invoice.delete(123)
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
`save` routes to `create` or `update` based on `meta.new?`.
|
|
568
|
+
|
|
569
|
+
### Custom query methods
|
|
570
|
+
|
|
571
|
+
Override or extend CRUD at the base resource level:
|
|
572
|
+
|
|
573
|
+
```ruby
|
|
574
|
+
class Fortnox::Resource < RestEasy::Resource
|
|
575
|
+
class << self
|
|
576
|
+
def find(id_or_hash)
|
|
577
|
+
return find_all_by(id_or_hash) if id_or_hash.is_a?(Hash)
|
|
578
|
+
find_one_by(id)
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def search(hash)
|
|
582
|
+
attribute, value = hash.first
|
|
583
|
+
response = get(path: config.path, params: { attribute => value })
|
|
584
|
+
parse(response)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def only(filter)
|
|
588
|
+
response = get(path: config.path, params: { filter: filter })
|
|
589
|
+
parse(response)
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
end
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
## Instance state
|
|
596
|
+
|
|
597
|
+
### Three namespaces
|
|
598
|
+
|
|
599
|
+
Every parsed instance exposes three namespaces:
|
|
600
|
+
|
|
601
|
+
```ruby
|
|
602
|
+
invoice = Fortnox::Invoice.parse(api_response)
|
|
603
|
+
|
|
604
|
+
# model — parsed attributes with Ruby names
|
|
605
|
+
invoice.model.customer_name # => "Acme Corp"
|
|
606
|
+
invoice.customer_name # => "Acme Corp" (shortcut)
|
|
607
|
+
invoice.model.attributes # => { customer_name: "Acme Corp", ... }
|
|
608
|
+
|
|
609
|
+
# api — shadow copy of the original API data
|
|
610
|
+
invoice.api.attributes # => { "CustomerName" => "Acme Corp", ... }
|
|
611
|
+
|
|
612
|
+
# meta — instance metadata
|
|
613
|
+
invoice.meta.new? # => false
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
### Immutable updates
|
|
617
|
+
|
|
618
|
+
`update` returns a new instance — the original is unchanged:
|
|
619
|
+
|
|
620
|
+
```ruby
|
|
621
|
+
original = Fortnox::Invoice.find(1)
|
|
622
|
+
changed = original.update(amount: 999.0)
|
|
623
|
+
|
|
624
|
+
original.amount # => 500.0 (unchanged)
|
|
625
|
+
changed.amount # => 999.0
|
|
626
|
+
changed.__changes__ # => { amount: 999.0 }
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
### Serialisation
|
|
630
|
+
|
|
631
|
+
```ruby
|
|
632
|
+
invoice.serialise # => { "CustomerName" => "Acme", ... } (Ruby hash, API names)
|
|
633
|
+
invoice.to_api # => '{"CustomerName":"Acme",...}' (JSON string, API names)
|
|
634
|
+
invoice.to_json # => '{"customer_name":"Acme",...}' (JSON string, model names)
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
Read-only attributes are excluded from `serialise` and `to_api`.
|
|
638
|
+
|
|
639
|
+
## Stubs
|
|
640
|
+
|
|
641
|
+
Create local instances that haven't been persisted:
|
|
642
|
+
|
|
643
|
+
```ruby
|
|
644
|
+
draft = Fortnox::Invoice.stub(customer_name: "Acme", amount: 100.0)
|
|
645
|
+
draft.meta.new? # => true
|
|
646
|
+
draft.meta.saved? # => false
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
Define defaults with `with_stub`:
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
class Acme::Invoice < RestEasy::Resource
|
|
653
|
+
with_stub amount: 0.0, currency: "SEK"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
invoice = Acme::Invoice.stub(customer_name: "Test")
|
|
657
|
+
invoice.amount # => 0.0 (from default)
|
|
658
|
+
invoice.currency # => "SEK"
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
## Resource-level settings
|
|
662
|
+
|
|
663
|
+
Add custom `Dry::Configurable` settings to any resource:
|
|
664
|
+
|
|
665
|
+
```ruby
|
|
666
|
+
class Fortnox::Resource < RestEasy::Resource
|
|
667
|
+
settings do
|
|
668
|
+
setting :instance_wrapper, reader: true
|
|
669
|
+
setting :collection_wrapper, reader: true
|
|
670
|
+
setting :filters, default: {}
|
|
671
|
+
end
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
class Fortnox::Invoice < Fortnox::Resource
|
|
675
|
+
configure do
|
|
676
|
+
path "invoices"
|
|
677
|
+
instance_wrapper "Invoice"
|
|
678
|
+
collection_wrapper "Invoices"
|
|
679
|
+
filters({ filter: String.enum("cancelled", "unpaid") })
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
Fortnox::Invoice.config.instance_wrapper # => "Invoice"
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Settings are inherited and isolated — child class changes don't affect parents.
|
|
687
|
+
|
|
688
|
+
## Debug mode
|
|
689
|
+
|
|
690
|
+
Enable per-resource warnings about API field mismatches:
|
|
691
|
+
|
|
692
|
+
```ruby
|
|
693
|
+
class Acme::Invoice < RestEasy::Resource
|
|
694
|
+
configure do
|
|
695
|
+
debug true
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
With debug on, RestEasy warns about:
|
|
701
|
+
- API fields not declared as attributes or explicitly ignored
|
|
702
|
+
- Declared attributes missing from the API response
|
|
703
|
+
|
|
704
|
+
## Error hierarchy
|
|
705
|
+
|
|
706
|
+
```
|
|
707
|
+
RestEasy::Error
|
|
708
|
+
├── RestEasy::AttributeError
|
|
709
|
+
│ ├── RestEasy::MissingAttributeError # Required attribute absent
|
|
710
|
+
│ └── RestEasy::ConstraintError # Type constraint violated
|
|
711
|
+
├── RestEasy::RequestError # HTTP request failed
|
|
712
|
+
├── RestEasy::AuthenticationError # Auth rejected
|
|
713
|
+
├── RestEasy::RemoteServerError # 5xx response
|
|
714
|
+
└── RestEasy::RateLimitError # Rate limited
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
## Full walkthrough: building an API gem
|
|
718
|
+
|
|
719
|
+
Here's how to build a complete API client gem, using patterns from real implementations.
|
|
720
|
+
|
|
721
|
+
### 1. Set up the gem structure
|
|
722
|
+
|
|
723
|
+
```
|
|
724
|
+
my_api/
|
|
725
|
+
├── lib/
|
|
726
|
+
│ ├── my_api.rb
|
|
727
|
+
│ └── my_api/
|
|
728
|
+
│ ├── resource.rb
|
|
729
|
+
│ └── resources/
|
|
730
|
+
│ ├── customer.rb
|
|
731
|
+
│ └── invoice.rb
|
|
732
|
+
├── my_api.gemspec
|
|
733
|
+
└── spec/
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
### 2. Create the API module
|
|
737
|
+
|
|
738
|
+
```ruby
|
|
739
|
+
# lib/my_api.rb
|
|
740
|
+
require "rest_easy"
|
|
741
|
+
require "zeitwerk"
|
|
742
|
+
|
|
743
|
+
loader = Zeitwerk::Loader.for_gem
|
|
744
|
+
loader.collapse("#{__dir__}/my_api/resources")
|
|
745
|
+
loader.setup
|
|
746
|
+
|
|
747
|
+
module MyAPI
|
|
748
|
+
extend RestEasy
|
|
749
|
+
|
|
750
|
+
configure do
|
|
751
|
+
base_url "https://api.example.com/v1"
|
|
752
|
+
max_retries 3
|
|
753
|
+
authentication RestEasy::Auth::PSK.new(api_key: ENV["MY_API_KEY"])
|
|
754
|
+
conversions.json_attributes = :PascalCase
|
|
755
|
+
end
|
|
756
|
+
end
|
|
757
|
+
```
|
|
758
|
+
|
|
759
|
+
### 3. Create the base resource
|
|
760
|
+
|
|
761
|
+
```ruby
|
|
762
|
+
# lib/my_api/resource.rb
|
|
763
|
+
class MyAPI::Resource < RestEasy::Resource
|
|
764
|
+
settings do
|
|
765
|
+
setting :instance_wrapper, reader: true
|
|
766
|
+
setting :collection_wrapper, reader: true
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
before_parse do |data, meta|
|
|
770
|
+
if data.key?("Meta")
|
|
771
|
+
meta.total = data["Meta"]["TotalRecords"]
|
|
772
|
+
meta.page = data["Meta"]["CurrentPage"]
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
if data.key?(config.instance_wrapper)
|
|
776
|
+
next data[config.instance_wrapper]
|
|
777
|
+
elsif data.key?(config.collection_wrapper)
|
|
778
|
+
next data[config.collection_wrapper]
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
after_serialise do |data|
|
|
783
|
+
{ config.instance_wrapper => data }
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
```
|
|
787
|
+
|
|
788
|
+
### 4. Define resources
|
|
789
|
+
|
|
790
|
+
```ruby
|
|
791
|
+
# lib/my_api/resources/customer.rb
|
|
792
|
+
class MyAPI::Customer < MyAPI::Resource
|
|
793
|
+
configure do
|
|
794
|
+
path "customers"
|
|
795
|
+
instance_wrapper "Customer"
|
|
796
|
+
collection_wrapper "Customers"
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
key :customer_number, String
|
|
800
|
+
attr :name, String, :required
|
|
801
|
+
attr :email, String
|
|
802
|
+
attr :organisation_number, String
|
|
803
|
+
attr :created_at, Date, :read_only
|
|
804
|
+
end
|
|
805
|
+
```
|
|
806
|
+
|
|
807
|
+
```ruby
|
|
808
|
+
# lib/my_api/resources/invoice.rb
|
|
809
|
+
class MyAPI::Invoice < MyAPI::Resource
|
|
810
|
+
using RestEasy::Refinements
|
|
811
|
+
|
|
812
|
+
configure do
|
|
813
|
+
path "invoices"
|
|
814
|
+
instance_wrapper "Invoice"
|
|
815
|
+
collection_wrapper "Invoices"
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
key :document_number, Integer, :read_only
|
|
819
|
+
|
|
820
|
+
attr :customer_number, String, :required
|
|
821
|
+
attr :invoice_date, Date
|
|
822
|
+
attr :due_date, Date
|
|
823
|
+
attr :total_amount, Float, :read_only
|
|
824
|
+
attr :currency, String
|
|
825
|
+
attr :vat <=> 'VAT', Float
|
|
826
|
+
attr :pdf_url <=> '@urlPDF', String, :read_only
|
|
827
|
+
|
|
828
|
+
ignore :internal_status_code
|
|
829
|
+
end
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
### 5. Use your gem
|
|
833
|
+
|
|
834
|
+
```ruby
|
|
835
|
+
require "my_api"
|
|
836
|
+
|
|
837
|
+
# Configure auth at runtime
|
|
838
|
+
MyAPI.configure do |config|
|
|
839
|
+
config.authentication = RestEasy::Auth::PSK.new(api_key: "live-key-123")
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Fetch records
|
|
843
|
+
customers = MyAPI::Customer.all
|
|
844
|
+
invoice = MyAPI::Invoice.find(10001)
|
|
845
|
+
|
|
846
|
+
# Create a new record
|
|
847
|
+
draft = MyAPI::Customer.stub(
|
|
848
|
+
name: "Acme Corp",
|
|
849
|
+
email: "billing@acme.com",
|
|
850
|
+
organisation_number: "556677-8899"
|
|
851
|
+
)
|
|
852
|
+
customer = MyAPI::Customer.create(draft)
|
|
853
|
+
|
|
854
|
+
# Update
|
|
855
|
+
updated = customer.update(email: "new@acme.com")
|
|
856
|
+
MyAPI::Customer.save(updated)
|
|
857
|
+
|
|
858
|
+
# Access metadata from hooks
|
|
859
|
+
invoices = MyAPI::Invoice.all
|
|
860
|
+
invoices.first.meta.total # => 142
|
|
861
|
+
invoices.first.meta.page # => 1
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
## License
|
|
865
|
+
|
|
866
|
+
MIT
|
data/lib/rest_easy/resource.rb
CHANGED
|
@@ -9,6 +9,11 @@ module RestEasy
|
|
|
9
9
|
setting :path
|
|
10
10
|
setting :debug, default: false
|
|
11
11
|
|
|
12
|
+
setting :conversions do
|
|
13
|
+
setting :query_parameters # nil default — falls back to parent module
|
|
14
|
+
setting :json_attributes # nil default — falls back to parent module
|
|
15
|
+
end
|
|
16
|
+
|
|
12
17
|
# ── Types ─────────────────────────────────────────────────────────────
|
|
13
18
|
# Include Types so the full Dry::Types vocabulary (Strict::String,
|
|
14
19
|
# Coercible::Integer, Params::Date, etc.) is available without prefix.
|
|
@@ -125,6 +130,8 @@ module RestEasy
|
|
|
125
130
|
# -- settings -------------------------------------------------------
|
|
126
131
|
|
|
127
132
|
def settings(&block)
|
|
133
|
+
return super() unless block
|
|
134
|
+
|
|
128
135
|
class_eval(&block)
|
|
129
136
|
end
|
|
130
137
|
|
|
@@ -143,16 +150,30 @@ module RestEasy
|
|
|
143
150
|
end
|
|
144
151
|
end
|
|
145
152
|
|
|
146
|
-
# --
|
|
153
|
+
# -- conversions ---------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def json_attribute_converter
|
|
156
|
+
Conventions.resolve(
|
|
157
|
+
config.conversions.json_attributes ||
|
|
158
|
+
parent&.config&.conversions&.json_attributes ||
|
|
159
|
+
Conventions::DEFAULT
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def query_parameter_converter
|
|
164
|
+
convention = config.conversions.query_parameters ||
|
|
165
|
+
parent&.config&.conversions&.query_parameters
|
|
166
|
+
convention && Conventions.resolve(convention)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# -- attribute_convention (deprecated) -------------------------------
|
|
147
170
|
|
|
148
171
|
def attribute_convention(value = nil)
|
|
149
172
|
if value
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
@attribute_convention ||
|
|
153
|
-
(superclass.respond_to?(:attribute_convention) ? superclass.attribute_convention : nil) ||
|
|
154
|
-
Conventions.resolve(parent&.config&.attribute_convention || :PascalCase)
|
|
173
|
+
warn "RestEasy: attribute_convention is deprecated, use `configure { conversions.json_attributes = #{value.inspect} }` instead"
|
|
174
|
+
config.conversions.json_attributes = value
|
|
155
175
|
end
|
|
176
|
+
json_attribute_converter
|
|
156
177
|
end
|
|
157
178
|
|
|
158
179
|
private
|
|
@@ -191,7 +212,7 @@ module RestEasy
|
|
|
191
212
|
attribute_api_name = name_or_mapping[1].to_s
|
|
192
213
|
else
|
|
193
214
|
attribute_model_name = name_or_mapping.to_sym
|
|
194
|
-
attribute_api_name =
|
|
215
|
+
attribute_api_name = json_attribute_converter.serialise(attribute_model_name)
|
|
195
216
|
end
|
|
196
217
|
|
|
197
218
|
# Extract type (non-Symbol), flags (Symbols), and optional mapper object
|
|
@@ -459,7 +480,9 @@ module RestEasy
|
|
|
459
480
|
# HTTP primitives — delegate to the parent API module's connection
|
|
460
481
|
|
|
461
482
|
def get(path:, params: {}, headers: {})
|
|
462
|
-
|
|
483
|
+
converter = query_parameter_converter
|
|
484
|
+
converted_params = converter ? params.transform_keys { |k| converter.serialise(k) } : params
|
|
485
|
+
parent.get(path:, params: converted_params, headers:)
|
|
463
486
|
end
|
|
464
487
|
|
|
465
488
|
def post(path:, body: nil, headers: {})
|
|
@@ -578,7 +601,7 @@ module RestEasy
|
|
|
578
601
|
serialised = attr_def.serialise_value(value)
|
|
579
602
|
if serialised.is_a?(::Array)
|
|
580
603
|
# Array return: zip with source field API names
|
|
581
|
-
convention = klass.
|
|
604
|
+
convention = klass.json_attribute_converter
|
|
582
605
|
attr_def.source_fields.zip(serialised).each do |field_name, field_value|
|
|
583
606
|
api_key = convention.serialise(field_name)
|
|
584
607
|
result[api_key] = field_value
|
|
@@ -655,7 +678,7 @@ module RestEasy
|
|
|
655
678
|
if attr_def.source_fields.any?
|
|
656
679
|
# Source fields declared via block params: extract individual
|
|
657
680
|
# values from api_data using convention, splat into parse block.
|
|
658
|
-
convention = klass.
|
|
681
|
+
convention = klass.json_attribute_converter
|
|
659
682
|
raw_values = attr_def.source_fields.map do |field_name|
|
|
660
683
|
api_key = convention.serialise(field_name)
|
|
661
684
|
api_data[api_key]
|
|
@@ -678,7 +701,7 @@ module RestEasy
|
|
|
678
701
|
|
|
679
702
|
if config.debug
|
|
680
703
|
# Warn about API fields that are neither declared attrs nor explicitly ignored
|
|
681
|
-
convention = klass.
|
|
704
|
+
convention = klass.json_attribute_converter
|
|
682
705
|
known_api_keys = klass.all_attribute_definitions.values.flat_map do |ad|
|
|
683
706
|
keys = [ad.api_name]
|
|
684
707
|
ad.source_fields.each { |sf| keys << convention.serialise(sf) }
|
data/lib/rest_easy/settings.rb
CHANGED
|
@@ -9,6 +9,11 @@ module RestEasy
|
|
|
9
9
|
setting :base_url, default: "https://example.com", reader: true
|
|
10
10
|
setting :max_retries, default: 3, reader: true
|
|
11
11
|
setting :authentication, default: Auth::Null.new, reader: true
|
|
12
|
-
setting :attribute_convention
|
|
12
|
+
setting :attribute_convention # deprecated — propagated to conversions.json_attributes in configure
|
|
13
|
+
|
|
14
|
+
setting :conversions do
|
|
15
|
+
setting :query_parameters, default: nil, reader: true
|
|
16
|
+
setting :json_attributes, default: Conventions::DEFAULT, reader: true
|
|
17
|
+
end
|
|
13
18
|
end
|
|
14
19
|
end
|
data/lib/rest_easy/version.rb
CHANGED
data/lib/rest_easy.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rubygems"
|
|
4
|
-
require "dry/inflector"
|
|
5
4
|
require "dry/types"
|
|
6
5
|
require "faraday"
|
|
7
6
|
require "zeitwerk"
|
|
@@ -83,6 +82,17 @@ module RestEasy
|
|
|
83
82
|
else
|
|
84
83
|
yield self::Settings.config
|
|
85
84
|
end
|
|
85
|
+
|
|
86
|
+
# Backwards compatibility: propagate the deprecated attribute_convention
|
|
87
|
+
# to conversions, but only on changes — so repeated `configure` calls
|
|
88
|
+
# don't re-warn and don't clobber a `conversions.json_attributes` set in
|
|
89
|
+
# a later call.
|
|
90
|
+
ac = self::Settings.config.attribute_convention
|
|
91
|
+
if ac && @_propagated_attribute_convention != ac
|
|
92
|
+
warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead"
|
|
93
|
+
self::Settings.config.conversions.json_attributes = ac
|
|
94
|
+
@_propagated_attribute_convention = ac
|
|
95
|
+
end
|
|
86
96
|
end
|
|
87
97
|
end
|
|
88
98
|
|
metadata
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rest-easy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonas Schubert Erlandsson
|
|
8
|
+
- Hannes Elvemyr
|
|
8
9
|
- Claude Code
|
|
9
10
|
autorequire:
|
|
10
11
|
bindir: bin
|
|
11
12
|
cert_chain: []
|
|
12
|
-
date: 2026-
|
|
13
|
+
date: 2026-05-15 00:00:00.000000000 Z
|
|
13
14
|
dependencies:
|
|
14
15
|
- !ruby/object:Gem::Dependency
|
|
15
16
|
name: dry-types
|
|
@@ -39,34 +40,20 @@ dependencies:
|
|
|
39
40
|
- - "~>"
|
|
40
41
|
- !ruby/object:Gem::Version
|
|
41
42
|
version: '2.6'
|
|
42
|
-
- !ruby/object:Gem::Dependency
|
|
43
|
-
name: dry-inflector
|
|
44
|
-
requirement: !ruby/object:Gem::Requirement
|
|
45
|
-
requirements:
|
|
46
|
-
- - "~>"
|
|
47
|
-
- !ruby/object:Gem::Version
|
|
48
|
-
version: 0.2.1
|
|
49
|
-
type: :runtime
|
|
50
|
-
prerelease: false
|
|
51
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
52
|
-
requirements:
|
|
53
|
-
- - "~>"
|
|
54
|
-
- !ruby/object:Gem::Version
|
|
55
|
-
version: 0.2.1
|
|
56
43
|
- !ruby/object:Gem::Dependency
|
|
57
44
|
name: dry-configurable
|
|
58
45
|
requirement: !ruby/object:Gem::Requirement
|
|
59
46
|
requirements:
|
|
60
47
|
- - "~>"
|
|
61
48
|
- !ruby/object:Gem::Version
|
|
62
|
-
version: '0
|
|
49
|
+
version: '1.0'
|
|
63
50
|
type: :runtime
|
|
64
51
|
prerelease: false
|
|
65
52
|
version_requirements: !ruby/object:Gem::Requirement
|
|
66
53
|
requirements:
|
|
67
54
|
- - "~>"
|
|
68
55
|
- !ruby/object:Gem::Version
|
|
69
|
-
version: '0
|
|
56
|
+
version: '1.0'
|
|
70
57
|
- !ruby/object:Gem::Dependency
|
|
71
58
|
name: faraday
|
|
72
59
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -85,51 +72,55 @@ dependencies:
|
|
|
85
72
|
name: bundler
|
|
86
73
|
requirement: !ruby/object:Gem::Requirement
|
|
87
74
|
requirements:
|
|
88
|
-
- - "
|
|
75
|
+
- - "~>"
|
|
89
76
|
- !ruby/object:Gem::Version
|
|
90
|
-
version: '0'
|
|
77
|
+
version: '2.0'
|
|
91
78
|
type: :development
|
|
92
79
|
prerelease: false
|
|
93
80
|
version_requirements: !ruby/object:Gem::Requirement
|
|
94
81
|
requirements:
|
|
95
|
-
- - "
|
|
82
|
+
- - "~>"
|
|
96
83
|
- !ruby/object:Gem::Version
|
|
97
|
-
version: '0'
|
|
84
|
+
version: '2.0'
|
|
98
85
|
- !ruby/object:Gem::Dependency
|
|
99
86
|
name: rake
|
|
100
87
|
requirement: !ruby/object:Gem::Requirement
|
|
101
88
|
requirements:
|
|
102
|
-
- - "
|
|
89
|
+
- - "~>"
|
|
103
90
|
- !ruby/object:Gem::Version
|
|
104
|
-
version: '0'
|
|
91
|
+
version: '13.0'
|
|
105
92
|
type: :development
|
|
106
93
|
prerelease: false
|
|
107
94
|
version_requirements: !ruby/object:Gem::Requirement
|
|
108
95
|
requirements:
|
|
109
|
-
- - "
|
|
96
|
+
- - "~>"
|
|
110
97
|
- !ruby/object:Gem::Version
|
|
111
|
-
version: '0'
|
|
98
|
+
version: '13.0'
|
|
112
99
|
- !ruby/object:Gem::Dependency
|
|
113
100
|
name: rspec
|
|
114
101
|
requirement: !ruby/object:Gem::Requirement
|
|
115
102
|
requirements:
|
|
116
|
-
- - "
|
|
103
|
+
- - "~>"
|
|
117
104
|
- !ruby/object:Gem::Version
|
|
118
|
-
version: '0'
|
|
105
|
+
version: '3.0'
|
|
119
106
|
type: :development
|
|
120
107
|
prerelease: false
|
|
121
108
|
version_requirements: !ruby/object:Gem::Requirement
|
|
122
109
|
requirements:
|
|
123
|
-
- - "
|
|
110
|
+
- - "~>"
|
|
124
111
|
- !ruby/object:Gem::Version
|
|
125
|
-
version: '0'
|
|
126
|
-
description:
|
|
112
|
+
version: '3.0'
|
|
113
|
+
description: Define your resources with a clean DSL, and RestEasy handles naming conventions,
|
|
114
|
+
type coercion, serialisation, authentication, and HTTP plumbing — so you can ship
|
|
115
|
+
an API gem with minimal boilerplate.
|
|
127
116
|
email:
|
|
128
117
|
- jonas@accodeing.com
|
|
129
118
|
executables: []
|
|
130
119
|
extensions: []
|
|
131
120
|
extra_rdoc_files: []
|
|
132
121
|
files:
|
|
122
|
+
- CHANGELOG.md
|
|
123
|
+
- README.md
|
|
133
124
|
- lib/rest_easy.rb
|
|
134
125
|
- lib/rest_easy/attribute.rb
|
|
135
126
|
- lib/rest_easy/auth.rb
|
|
@@ -161,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
161
152
|
- !ruby/object:Gem::Version
|
|
162
153
|
version: '0'
|
|
163
154
|
requirements: []
|
|
164
|
-
rubygems_version: 3.
|
|
155
|
+
rubygems_version: 3.4.6
|
|
165
156
|
signing_key:
|
|
166
157
|
specification_version: 4
|
|
167
158
|
summary: Boilerplate for REST API libraries, using on dry-rb
|