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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45f897c2480954795efd3cae882ba4502ba38f7eeeb857f4a5a5ae897f9cc8f1
4
- data.tar.gz: e7f42336f9d418c2c97b628cc5d393bdfd07472152c452ca60ebac770da02f49
3
+ metadata.gz: ec1e2b4f51ad07119add9f89dd2075a765fa2fc258766294aa7b05c1bc4e2951
4
+ data.tar.gz: 350b2381fd3e0dd4cde1815000684c474238ed5c5f8d438611a8fb5de9e381b8
5
5
  SHA512:
6
- metadata.gz: 7e2b2ad556be8737a7f85247e1d70a391d16bae3df56b6c9203ac3f746a6c54611218b18d34340d2b078a4f2b9eeb9c85ad15083e4cf75de07e8dc7c93033f45
7
- data.tar.gz: b3fce6ba8f12d94e89df4b7325abc07d1ead9dd687b460cddd7df2c4264b21beeb1b85eba6770dc82a8894100be4d5d7b9f650b84b15d9fc6e474fc7b6d73744
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
+ [![Gem Version](https://badge.fury.io/rb/rest-easy.svg?icon=si%3Arubygems)](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
@@ -56,6 +56,8 @@ module RestEasy
56
56
  snake_case: SnakeCase.new
57
57
  }.freeze
58
58
 
59
+ DEFAULT = :PascalCase
60
+
59
61
  def self.resolve(convention)
60
62
  case convention
61
63
  when Symbol
@@ -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
- # -- attribute_convention ------------------------------------------
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
- @attribute_convention = Conventions.resolve(value)
151
- else
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 = attribute_convention.serialise(attribute_model_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
- parent.get(path:, params:, headers:)
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.attribute_convention
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.attribute_convention
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.attribute_convention
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) }
@@ -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, default: :PascalCase, reader: true
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RestEasy
4
- VERSION = "1.0.0"
4
+ VERSION = "1.1.2"
5
5
  end
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.0.0
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-03-19 00:00:00.000000000 Z
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.14'
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.14'
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: Boilerplate for REST API libraries, using on dry-rb
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.3.7
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