From bbb73bc99e9a8ebd7d588d3cff129bb84abb7a2c Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:11:45 +0200 Subject: [PATCH 1/6] Adds separate configs for attributes and parameters --- CHANGELOG.md | 30 ++ Gemfile.lock | 2 +- lib/rest_easy/conventions.rb | 2 + lib/rest_easy/resource.rb | 44 ++- lib/rest_easy/settings.rb | 7 +- lib/rest_easy/version.rb | 2 +- spec/rest_easy/resource/conversions_spec.rb | 339 ++++++++++++++++++++ spec/rest_easy/resource_spec.rb | 133 ++++++++ 8 files changed, 548 insertions(+), 11 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 spec/rest_easy/resource/conversions_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..002cd97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +## [1.1.0] - 2026-03-25 + +### Added + +- **`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: + + ```ruby + module MyAPI + extend RestEasy + + configure do + conversions.json_attributes = :camelCase + conversions.query_parameters = :PascalCase + end + end + ``` + +- **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. + +- `conversions` can be overridden per Resource class, with inheritance falling back to the parent API module configuration. + +### Deprecated + +- **`attribute_convention`** is deprecated in favour of `conversions.json_attributes`. The old setting continues to work and is respected as a fallback, but emits a deprecation warning when used at the Resource level. Module-level `attribute_convention` is silently supported for backwards compatibility. + +## [1.0.0] + +Initial release. diff --git a/Gemfile.lock b/Gemfile.lock index 7ced8de..25c3bda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rest-easy (0.1.0) + rest-easy (1.1.0) dry-configurable (~> 0.14) dry-inflector (~> 0.2.1) dry-types (~> 1.2) diff --git a/lib/rest_easy/conventions.rb b/lib/rest_easy/conventions.rb index f0b3307..13bea6d 100644 --- a/lib/rest_easy/conventions.rb +++ b/lib/rest_easy/conventions.rb @@ -56,6 +56,8 @@ def serialise(model_name) snake_case: SnakeCase.new }.freeze + ConventionPair = Struct.new(:query_parameters, :json_attributes, keyword_init: true) + def self.resolve(convention) case convention when Symbol diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 8a759db..41c135d 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -9,6 +9,11 @@ class Resource setting :path setting :debug, default: false + setting :conversions do + setting :query_parameters # nil default — falls back to parent module + setting :json_attributes # nil default — falls back to parent module + end + # ── Types ───────────────────────────────────────────────────────────── # Include Types so the full Dry::Types vocabulary (Strict::String, # Coercible::Integer, Params::Date, etc.) is available without prefix. @@ -143,16 +148,35 @@ def metadata(**kwargs) end end - # -- attribute_convention ------------------------------------------ + # -- conversions --------------------------------------------------- + + def resolved_conversions + @resolved_conversions ||= begin + qp = config.conversions.query_parameters || + parent&.config&.conversions&.query_parameters || + :snake_case + + ja = config.conversions.json_attributes || + parent&.config&.conversions&.json_attributes || + parent&.config&.attribute_convention || # BC: old setting as fallback + :snake_case + + Conventions::ConventionPair.new( + query_parameters: Conventions.resolve(qp), + json_attributes: Conventions.resolve(ja) + ) + end + end + + # -- attribute_convention (deprecated) ------------------------------- def attribute_convention(value = nil) if value - @attribute_convention = Conventions.resolve(value) - else - @attribute_convention || - (superclass.respond_to?(:attribute_convention) ? superclass.attribute_convention : nil) || - Conventions.resolve(parent&.config&.attribute_convention || :PascalCase) + warn "RestEasy: attribute_convention is deprecated, use `configure { conversions.json_attributes = #{value.inspect} }` instead" + config.conversions.json_attributes = value + @resolved_conversions = nil # bust memoization end + resolved_conversions.json_attributes end private @@ -191,7 +215,7 @@ def attr(name_or_mapping, *args, &block) attribute_api_name = name_or_mapping[1].to_s else attribute_model_name = name_or_mapping.to_sym - attribute_api_name = attribute_convention.serialise(attribute_model_name) + attribute_api_name = resolved_conversions.json_attributes.serialise(attribute_model_name) end # Extract type (non-Symbol), flags (Symbols), and optional mapper object @@ -459,6 +483,10 @@ def delete(id) # HTTP primitives — delegate to the parent API module's connection def get(path:, params: {}, headers: {}) + if params.any? + conv = resolved_conversions.query_parameters + params = params.transform_keys { |k| conv.serialise(k) } + end parent.get(path:, params:, headers:) end @@ -578,7 +606,7 @@ def serialise serialised = attr_def.serialise_value(value) if serialised.is_a?(::Array) # Array return: zip with source field API names - convention = klass.attribute_convention + convention = klass.resolved_conversions.json_attributes attr_def.source_fields.zip(serialised).each do |field_name, field_value| api_key = convention.serialise(field_name) result[api_key] = field_value diff --git a/lib/rest_easy/settings.rb b/lib/rest_easy/settings.rb index 56d55ea..d261bb1 100644 --- a/lib/rest_easy/settings.rb +++ b/lib/rest_easy/settings.rb @@ -9,6 +9,11 @@ class Settings setting :base_url, default: "https://example.com", reader: true setting :max_retries, default: 3, reader: true setting :authentication, default: Auth::Null.new, reader: true - setting :attribute_convention, default: :PascalCase, reader: true + setting :attribute_convention, default: :PascalCase, reader: true # deprecated, kept for BC + + setting :conversions do + setting :query_parameters, reader: true # nil default — :snake_case resolved in Resource + setting :json_attributes, reader: true # nil default — :snake_case resolved in Resource + end end end diff --git a/lib/rest_easy/version.rb b/lib/rest_easy/version.rb index 61fc7c2..070d118 100644 --- a/lib/rest_easy/version.rb +++ b/lib/rest_easy/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RestEasy - VERSION = "1.0.0" + VERSION = "1.1.0" end diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb new file mode 100644 index 0000000..e135e80 --- /dev/null +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -0,0 +1,339 @@ +# frozen_string_literal: true + +RSpec.describe "Resource conversions" do + # Helper to set up a Faraday test adapter with stubs + def setup_test_connection(api_module, &block) + stubs = Faraday::Adapter::Test::Stubs.new(&block) + api_module.instance_variable_set(:@faraday_connection, nil) + api_module.connection do |f| + f.request :json + f.response :json, content_type: /\bjson$/ + f.adapter :test, stubs + end + stubs + end + + # ── Module-level configuration ────────────────────────────────────── + + describe "module-level conversions" do + before(:all) do + module ConvTestApi + extend RestEasy + + configure do + conversions.json_attributes = :PascalCase + conversions.query_parameters = :camelCase + end + end + + class ConvTestApi::Invoice < RestEasy::Resource + configure { path "invoices" } + + key :document_number, Integer, :read_only + attr :customer_name, String + end + end + + after(:all) do + Object.send(:remove_const, :ConvTestApi) + end + + it "resolves json_attributes from module config" do + conv = ConvTestApi::Invoice.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + end + + it "resolves query_parameters from module config" do + conv = ConvTestApi::Invoice.resolved_conversions + expect(conv.query_parameters).to be_a(RestEasy::Conventions::CamelCase) + end + + it "parses API data using json_attributes convention" do + instance = ConvTestApi::Invoice.parse({ + "DocumentNumber" => 1, + "CustomerName" => "Acme" + }) + + expect(instance.document_number).to eq(1) + expect(instance.customer_name).to eq("Acme") + end + + it "serialises using json_attributes convention" do + instance = ConvTestApi::Invoice.parse({ + "DocumentNumber" => 1, + "CustomerName" => "Acme" + }) + serialised = instance.serialise + + # DocumentNumber is :read_only, so excluded from serialise + expect(serialised).to have_key("CustomerName") + expect(serialised["CustomerName"]).to eq("Acme") + end + + it "transforms query parameter keys using query_parameters convention" do + captured_params = nil + + setup_test_connection(ConvTestApi) do |stub| + stub.get("/invoices") do |env| + captured_params = env.params + [200, { "Content-Type" => "application/json" }, + '[{"DocumentNumber": 1, "CustomerName": "Test"}]'] + end + end + + ConvTestApi::Invoice.get( + path: "invoices", + params: { customer_name: "Test", sort_order: "asc" } + ) + + expect(captured_params).to include("customerName" => "Test", "sortOrder" => "asc") + end + end + + # ── Resource-level override ───────────────────────────────────────── + + describe "resource-level override" do + before(:all) do + module ResOverrideApi + extend RestEasy + + configure do + conversions.json_attributes = :camelCase + conversions.query_parameters = :camelCase + end + end + + class ResOverrideApi::Base < RestEasy::Resource + end + + class ResOverrideApi::Standard < ResOverrideApi::Base + attr :item_name, String + end + + class ResOverrideApi::Custom < ResOverrideApi::Base + configure do + conversions.json_attributes = :PascalCase + conversions.query_parameters = :PascalCase + end + + attr :item_name, String + end + end + + after(:all) do + Object.send(:remove_const, :ResOverrideApi) + end + + it "inherits module-level convention when not overridden" do + conv = ResOverrideApi::Standard.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + expect(conv.query_parameters).to be_a(RestEasy::Conventions::CamelCase) + end + + it "uses resource-level convention when overridden" do + conv = ResOverrideApi::Custom.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + end + + it "parses with inherited convention" do + instance = ResOverrideApi::Standard.parse({ "itemName" => "Widget" }) + expect(instance.item_name).to eq("Widget") + end + + it "parses with overridden convention" do + instance = ResOverrideApi::Custom.parse({ "ItemName" => "Widget" }) + expect(instance.item_name).to eq("Widget") + end + + it "does not affect sibling resources" do + standard_conv = ResOverrideApi::Standard.resolved_conversions + custom_conv = ResOverrideApi::Custom.resolved_conversions + + expect(standard_conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + expect(custom_conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + end + end + + # ── Partial override (one key only) ───────────────────────────────── + + describe "partial override" do + before(:all) do + module PartialApi + extend RestEasy + + configure do + conversions.json_attributes = :camelCase + conversions.query_parameters = :camelCase + end + end + + class PartialApi::Resource < RestEasy::Resource + configure do + conversions.query_parameters = :PascalCase + # json_attributes not set — inherits from module + end + + attr :item_name, String + end + end + + after(:all) do + Object.send(:remove_const, :PartialApi) + end + + it "uses overridden query_parameters" do + conv = PartialApi::Resource.resolved_conversions + expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + end + + it "inherits json_attributes from module" do + conv = PartialApi::Resource.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + end + end + + # ── Independent conventions ───────────────────────────────────────── + + describe "independent query_parameters and json_attributes" do + before(:all) do + module MixedApi + extend RestEasy + + configure do + conversions.json_attributes = :camelCase + conversions.query_parameters = :PascalCase + end + end + + class MixedApi::Item < RestEasy::Resource + configure { path "items" } + attr :item_name, String + end + end + + after(:all) do + Object.send(:remove_const, :MixedApi) + end + + it "uses different conventions for attributes and parameters" do + conv = MixedApi::Item.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + end + + it "serialises attributes as camelCase" do + instance = MixedApi::Item.parse({ "itemName" => "Widget" }) + serialised = instance.serialise + expect(serialised).to have_key("itemName") + end + + it "transforms query params as PascalCase" do + captured_params = nil + + setup_test_connection(MixedApi) do |stub| + stub.get("/items") do |env| + captured_params = env.params + [200, { "Content-Type" => "application/json" }, + '[{"itemName": "Widget"}]'] + end + end + + MixedApi::Item.get(path: "items", params: { item_name: "Widget" }) + + expect(captured_params).to include("ItemName" => "Widget") + end + end + + # ── Backwards compatibility ───────────────────────────────────────── + + describe "backwards compatibility" do + describe "module-level attribute_convention" do + before(:all) do + module BCModuleApi + extend RestEasy + + configure do |config| + config.attribute_convention = :PascalCase + end + end + + class BCModuleApi::Invoice < RestEasy::Resource + attr :customer_name, String + end + end + + after(:all) do + Object.send(:remove_const, :BCModuleApi) + end + + it "resolves json_attributes from old attribute_convention setting" do + conv = BCModuleApi::Invoice.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + end + + it "defaults query_parameters to snake_case" do + conv = BCModuleApi::Invoice.resolved_conversions + expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) + end + + it "parses with the old convention" do + instance = BCModuleApi::Invoice.parse({ "CustomerName" => "Acme" }) + expect(instance.customer_name).to eq("Acme") + end + end + + describe "resource-level attribute_convention" do + it "sets json_attributes and emits a deprecation warning" do + resource_class = Class.new(RestEasy::Resource) + + expect { + resource_class.attribute_convention :camelCase + }.to output(/deprecated/).to_stderr + + expect(resource_class.resolved_conversions.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + end + + it "still works as a getter" do + resource_class = Class.new(RestEasy::Resource) do + configure { conversions.json_attributes = :PascalCase } + attr :item_name, String + end + + # Suppress deprecation warning — we're testing the getter path + expect(resource_class.attribute_convention).to be_a(RestEasy::Conventions::PascalCase) + end + end + end + + # ── Default resolution ────────────────────────────────────────────── + + describe "default resolution" do + before(:all) do + module DefaultApi + extend RestEasy + # No conversions or attribute_convention set + end + + class DefaultApi::Thing < RestEasy::Resource + attr :my_field, String + end + end + + after(:all) do + Object.send(:remove_const, :DefaultApi) + end + + it "falls back to attribute_convention default for json_attributes" do + # The old attribute_convention defaults to :PascalCase on Settings, + # so json_attributes picks that up via the BC fallback + conv = DefaultApi::Thing.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + end + + it "defaults query_parameters to snake_case" do + conv = DefaultApi::Thing.resolved_conversions + expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) + end + end +end diff --git a/spec/rest_easy/resource_spec.rb b/spec/rest_easy/resource_spec.rb index 7f46c70..40ee892 100644 --- a/spec/rest_easy/resource_spec.rb +++ b/spec/rest_easy/resource_spec.rb @@ -25,6 +25,8 @@ module TestApi describe "path" do it "sets the endpoint path via configure" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + configure do path "invoices" end @@ -41,6 +43,8 @@ module TestApi describe "metadata" do it "sets default meta values on parsed instances" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String metadata partial: true end @@ -51,6 +55,8 @@ module TestApi it "sets default meta values on stubbed instances" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String metadata partial: true end @@ -61,6 +67,8 @@ module TestApi it "preserves defaults through update" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String metadata partial: true end @@ -72,6 +80,8 @@ module TestApi it "allows instance-level override of defaults" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String metadata partial: true end @@ -83,6 +93,8 @@ module TestApi it "inherits metadata from parent resource" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + metadata partial: true end @@ -98,6 +110,8 @@ module TestApi it "returns empty hash when no metadata defined" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String end @@ -111,6 +125,8 @@ module TestApi describe "simple declaration" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String attr :age, Integer attr :active, Boolean @@ -305,6 +321,8 @@ def serialise(model_name) describe "explicit API name mapping with <=>" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + using RestEasy::Refinements attr :tax_reduction_list_url <=> '@urlTaxReductionList', String, :read_only, :optional @@ -322,6 +340,8 @@ def serialise(model_name) describe "attribute flags" do it "supports :required flag" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String, :required end @@ -332,6 +352,8 @@ def serialise(model_name) it "supports :optional flag" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String, :optional end @@ -341,6 +363,8 @@ def serialise(model_name) it "supports :read_only flag" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :balance, Float, :read_only end @@ -355,6 +379,8 @@ def serialise(model_name) describe "custom parse/serialise with block" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + using RestEasy::Refinements attr :clean_field <=> :raw_field, String do @@ -395,6 +421,8 @@ def self.serialise(value) end @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + using RestEasy::Refinements attr :clean_field <=> :raw_field, String, mapper @@ -427,6 +455,8 @@ def self.serialise(full_name) end @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :first_name, String attr :last_name, String attr :full_name, String, mapper @@ -473,6 +503,8 @@ def self.serialise(street, city) end @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :street, String attr :city, String attr :address, String, mapper @@ -498,6 +530,8 @@ def self.serialise(street, city) describe "key" do it "declares the unique identifier attribute" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :document_number, Integer, :read_only end @@ -508,6 +542,8 @@ def self.serialise(street, city) it "is equivalent to attr with :key flag" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :id, Integer, :key end @@ -519,6 +555,8 @@ def self.serialise(street, city) it "warns when called more than once" do expect { Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer key :other_id, Integer end @@ -531,6 +569,8 @@ def self.serialise(street, city) describe "ignore" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String ignore :internal_field end @@ -566,6 +606,8 @@ def self.serialise(street, city) it "does not warn about explicitly ignored fields" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String ignore :internal_field end @@ -577,6 +619,11 @@ def self.serialise(street, city) it "warns about undeclared API fields" do resource_class = Class.new(described_class) do + configure do + conversions.json_attributes = :PascalCase + debug true + end + attr :name, String end @@ -591,6 +638,8 @@ def self.serialise(street, city) describe "synthetic attributes via attr block" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :first_name, String attr :last_name, String @@ -655,6 +704,8 @@ def self.serialise(street, city) describe "bare block as implicit parse" do it "treats a block with params as an implicit parse block" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :full_name, String, :read_only do |first_name, last_name| "#{first_name} #{last_name}" end @@ -670,6 +721,8 @@ def self.serialise(street, city) it "extracts source_fields from bare block param names" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :full_name, String, :read_only do |first_name, last_name| "#{first_name} #{last_name}" end @@ -682,6 +735,8 @@ def self.serialise(street, city) it "works with single-param bare block for split pattern" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :city, String do |address| address["city"] end @@ -696,6 +751,8 @@ def self.serialise(street, city) it "serialises under own API name when no serialise block is defined" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :city, String do |address| address["city"] end @@ -716,6 +773,8 @@ def self.serialise(street, city) describe "multi-param serialise block" do it "gathers model values by param names and splats into block" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :street, String attr :city, String @@ -733,6 +792,8 @@ def self.serialise(street, city) it "stores target_fields from serialise block param names" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :street, String attr :city, String @@ -752,6 +813,8 @@ def self.serialise(street, city) describe "before_parse" do it "pre-processes API data before attribute parsing" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + before_parse do |api_data| api_data["Invoice"] end @@ -769,6 +832,8 @@ def self.serialise(street, city) after_parse_called = false resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String after_parse do |model| @@ -786,6 +851,8 @@ def self.serialise(street, city) before_serialise_called = false resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String before_serialise do |model| @@ -802,6 +869,8 @@ def self.serialise(street, city) describe "after_serialise" do it "post-processes API data after serialisation" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String after_serialise do |api_data| @@ -819,6 +888,8 @@ def self.serialise(street, city) describe "before_parse with collections" do it "unwraps envelope before parsing a collection" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + before_parse do |api_data| api_data["Invoices"] end @@ -841,6 +912,8 @@ def self.serialise(street, city) describe "hook inheritance" do it "inherits hooks from parent classes" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + before_parse do |api_data| api_data["Wrapper"] end @@ -856,6 +929,8 @@ def self.serialise(street, city) it "resolves config from the calling class in inherited before_parse hook" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :instance_wrapper end @@ -884,6 +959,8 @@ def self.serialise(street, city) describe "instance state" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer attr :name, String ignore :internal @@ -944,6 +1021,8 @@ def self.serialise(street, city) describe "change tracking" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer attr :name, String attr :amount, Float @@ -980,6 +1059,8 @@ def self.serialise(street, city) describe "serialisation" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer, :read_only attr :name, String attr :balance, Float, :read_only @@ -1027,6 +1108,8 @@ def self.serialise(street, city) describe "equality" do before do @resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer attr :name, String end @@ -1048,6 +1131,8 @@ def self.serialise(street, city) it "considers instances of different classes unequal" do other_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + key :id, Integer attr :name, String end @@ -1064,6 +1149,8 @@ def self.serialise(street, city) describe "type coercion" do it "coerces string to integer" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :count, Integer end @@ -1073,6 +1160,8 @@ def self.serialise(street, city) it "coerces string to float" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :amount, Float end @@ -1082,6 +1171,8 @@ def self.serialise(street, city) it "supports type constraints" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String.constrained(max_size: 5) end @@ -1093,6 +1184,8 @@ def self.serialise(street, city) context "via update" do it "coerces values through the attribute type" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :count, Integer end @@ -1103,6 +1196,8 @@ def self.serialise(street, city) it "rejects values that violate constraints" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String.constrained(max_size: 5) end @@ -1114,6 +1209,8 @@ def self.serialise(street, city) it "passes nil through without coercion" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String end @@ -1126,6 +1223,8 @@ def self.serialise(street, city) context "via stub" do it "coerces values through the attribute type" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :count, Integer end @@ -1135,6 +1234,8 @@ def self.serialise(street, city) it "rejects values that violate constraints" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String.constrained(max_size: 5) end @@ -1145,6 +1246,8 @@ def self.serialise(street, city) it "passes nil through without coercion" do resource_class = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + attr :name, String end @@ -1159,6 +1262,8 @@ def self.serialise(street, city) describe "settings" do it "declares a setting via the settings block" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1169,6 +1274,8 @@ def self.serialise(street, city) it "allows reading settings via config" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :collection_name, default: "items" end @@ -1180,6 +1287,8 @@ def self.serialise(street, city) it "supports reader: true for accessor methods" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true, reader: true end @@ -1190,6 +1299,8 @@ def self.serialise(street, city) it "inherits settings from parent resource" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1204,6 +1315,8 @@ def self.serialise(street, city) it "isolates config between sibling classes" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1221,6 +1334,8 @@ def self.serialise(street, city) it "allows child to override inherited defaults without affecting parent" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1235,6 +1350,8 @@ def self.serialise(street, city) it "accumulates settings from multiple levels" do grandparent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1256,6 +1373,8 @@ def self.serialise(street, city) it "exposes config on instances for use in hooks" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: true end @@ -1268,6 +1387,8 @@ def self.serialise(street, city) it "exposes configure-set values on instances" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + configure do path "/invoices" end @@ -1280,6 +1401,8 @@ def self.serialise(street, city) it "exposes inherited configure-set values on instances" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :wrapper, default: false end @@ -1303,6 +1426,8 @@ def self.serialise(street, city) describe "configure" do it "sets a config value via method-call syntax" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :adapter, default: :rest end @@ -1317,6 +1442,8 @@ def self.serialise(street, city) it "sets multiple values in one block" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :adapter, default: :rest setting :pool, default: 1 @@ -1334,6 +1461,8 @@ def self.serialise(street, city) it "works with nested settings" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :database do setting :dsn, default: "sqlite:memory" @@ -1350,6 +1479,8 @@ def self.serialise(street, city) it "inherits settings and allows child to configure them" do parent = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :adapter, default: :rest end @@ -1367,6 +1498,8 @@ def self.serialise(street, city) it "can be called after class definition" do resource = Class.new(described_class) do + configure { conversions.json_attributes = :PascalCase } + settings do setting :pool, default: 1 end From d21f455d6a36bf196e0c62d651530a414453a4ad Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:16:05 +0200 Subject: [PATCH 2/6] Updates README.md with new config structure --- README.md | 41 +++++++++++++++++++++++++++++------------ 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e3623f9..675aafc 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ module Acme configure do base_url "https://api.acme.com/v1" authentication RestEasy::Auth::PSK.new(api_key: ENV["ACME_API_KEY"]) - attribute_convention :PascalCase + conversions.json_attributes = :PascalCase end end @@ -85,19 +85,21 @@ module Fortnox base_url "https://api.fortnox.se/3" max_retries 3 authentication RestEasy::Auth::PSK.new(api_key: ENV["FORTNOX_KEY"]) - attribute_convention :PascalCase + conversions.json_attributes = :PascalCase + conversions.query_parameters = :PascalCase end end ``` ### Available settings -| Setting | Default | Description | -|------------------------|----------------------------|------------------------------------------| -| `base_url` | `"https://example.com"` | Base URL for all requests | -| `max_retries` | `3` | Retry count on request failure | -| `authentication` | `Auth::Null.new` | Authentication strategy | -| `attribute_convention` | `:PascalCase` | Naming convention for API field mapping | +| Setting | Default | Description | +|----------------------------------|----------------------------|---------------------------------------------------| +| `base_url` | `"https://example.com"` | Base URL for all requests | +| `max_retries` | `3` | Retry count on request failure | +| `authentication` | `Auth::Null.new` | Authentication strategy | +| `conversions.json_attributes` | `:snake_case` | Naming convention for JSON response/request fields| +| `conversions.query_parameters` | `:snake_case` | Naming convention for query parameter keys | ### Faraday middleware @@ -189,7 +191,7 @@ The full `Dry::Types` vocabulary is available inside resource bodies — `Strict ### Naming conventions -RestEasy automatically maps between Ruby's `snake_case` attribute names and the API's naming convention: +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: | Convention | Ruby attr | API field | |---------------|--------------------|----------------------| @@ -197,12 +199,27 @@ RestEasy automatically maps between Ruby's `snake_case` attribute names and the | `:camelCase` | `:document_number` | `"documentNumber"` | | `:snake_case` | `:document_number` | `"document_number"` | -Set the convention at the module level (applies to all resources) or override per resource: +Set conventions at the module level (applies to all resources): ```ruby -attribute_convention :camelCase +configure do + conversions.json_attributes = :camelCase + conversions.query_parameters = :PascalCase +end ``` +Or override per resource: + +```ruby +class MyAPI::Special < MyAPI::Resource + configure do + conversions.json_attributes = :PascalCase + end +end +``` + +Query parameter keys are automatically transformed when calling `get` with `params:`. For example, with `query_parameters: :PascalCase`, `params: { sort_order: "asc" }` becomes `?SortOrder=asc` in the request. + You can also provide a custom convention object with `parse(api_name)` and `serialise(model_name)` methods. ### Explicit name mapping @@ -735,7 +752,7 @@ module MyAPI base_url "https://api.example.com/v1" max_retries 3 authentication RestEasy::Auth::PSK.new(api_key: ENV["MY_API_KEY"]) - attribute_convention :PascalCase + conversions.json_attributes = :PascalCase end end ``` From 4b2f10b20f9ed6f27c9cec5f6f84f6c8b8c4a027 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:22:06 +0200 Subject: [PATCH 3/6] Moves resolver defaults to parent module settings --- lib/rest_easy/resource.rb | 7 +--- lib/rest_easy/settings.rb | 4 +- spec/rest_easy/resource/conversions_spec.rb | 41 +-------------------- spec/rest_easy/resource/crud_spec.rb | 2 +- spec/rest_easy/resource/http_spec.rb | 1 + spec/rest_easy/resource/inheritance_spec.rb | 2 +- 6 files changed, 9 insertions(+), 48 deletions(-) diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 41c135d..37da290 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -153,13 +153,10 @@ def metadata(**kwargs) def resolved_conversions @resolved_conversions ||= begin qp = config.conversions.query_parameters || - parent&.config&.conversions&.query_parameters || - :snake_case + parent&.config&.conversions&.query_parameters ja = config.conversions.json_attributes || - parent&.config&.conversions&.json_attributes || - parent&.config&.attribute_convention || # BC: old setting as fallback - :snake_case + parent&.config&.conversions&.json_attributes Conventions::ConventionPair.new( query_parameters: Conventions.resolve(qp), diff --git a/lib/rest_easy/settings.rb b/lib/rest_easy/settings.rb index d261bb1..038253f 100644 --- a/lib/rest_easy/settings.rb +++ b/lib/rest_easy/settings.rb @@ -12,8 +12,8 @@ class Settings setting :attribute_convention, default: :PascalCase, reader: true # deprecated, kept for BC setting :conversions do - setting :query_parameters, reader: true # nil default — :snake_case resolved in Resource - setting :json_attributes, reader: true # nil default — :snake_case resolved in Resource + setting :query_parameters, default: :snake_case, reader: true + setting :json_attributes, default: :snake_case, reader: true end end end diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index e135e80..473ebc1 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -248,41 +248,6 @@ class MixedApi::Item < RestEasy::Resource # ── Backwards compatibility ───────────────────────────────────────── describe "backwards compatibility" do - describe "module-level attribute_convention" do - before(:all) do - module BCModuleApi - extend RestEasy - - configure do |config| - config.attribute_convention = :PascalCase - end - end - - class BCModuleApi::Invoice < RestEasy::Resource - attr :customer_name, String - end - end - - after(:all) do - Object.send(:remove_const, :BCModuleApi) - end - - it "resolves json_attributes from old attribute_convention setting" do - conv = BCModuleApi::Invoice.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) - end - - it "defaults query_parameters to snake_case" do - conv = BCModuleApi::Invoice.resolved_conversions - expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) - end - - it "parses with the old convention" do - instance = BCModuleApi::Invoice.parse({ "CustomerName" => "Acme" }) - expect(instance.customer_name).to eq("Acme") - end - end - describe "resource-level attribute_convention" do it "sets json_attributes and emits a deprecation warning" do resource_class = Class.new(RestEasy::Resource) @@ -324,11 +289,9 @@ class DefaultApi::Thing < RestEasy::Resource Object.send(:remove_const, :DefaultApi) end - it "falls back to attribute_convention default for json_attributes" do - # The old attribute_convention defaults to :PascalCase on Settings, - # so json_attributes picks that up via the BC fallback + it "defaults json_attributes to snake_case" do conv = DefaultApi::Thing.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + expect(conv.json_attributes).to be_a(RestEasy::Conventions::SnakeCase) end it "defaults query_parameters to snake_case" do diff --git a/spec/rest_easy/resource/crud_spec.rb b/spec/rest_easy/resource/crud_spec.rb index 984438d..fe1f441 100644 --- a/spec/rest_easy/resource/crud_spec.rb +++ b/spec/rest_easy/resource/crud_spec.rb @@ -7,7 +7,7 @@ module CrudTestApi configure do |config| config.base_url = "https://api.example.com/v1" - config.attribute_convention = :PascalCase + config.conversions.json_attributes = :PascalCase config.max_retries = 3 end end diff --git a/spec/rest_easy/resource/http_spec.rb b/spec/rest_easy/resource/http_spec.rb index dbe97ca..7b85c33 100644 --- a/spec/rest_easy/resource/http_spec.rb +++ b/spec/rest_easy/resource/http_spec.rb @@ -8,6 +8,7 @@ module HttpTestApi configure do |config| config.base_url = "https://api.example.com/v1" config.max_retries = 3 + config.conversions.json_attributes = :PascalCase end end diff --git a/spec/rest_easy/resource/inheritance_spec.rb b/spec/rest_easy/resource/inheritance_spec.rb index 7f2922f..ad0b19f 100644 --- a/spec/rest_easy/resource/inheritance_spec.rb +++ b/spec/rest_easy/resource/inheritance_spec.rb @@ -10,7 +10,7 @@ module InheritanceTestApi configure do |config| config.base_url = "https://api.example.com" - config.attribute_convention = :PascalCase + config.conversions.json_attributes = :PascalCase end end From 82d9f0954ec6b0c8f4cea63bb94788963265a40d Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:29:44 +0200 Subject: [PATCH 4/6] Proxies old module setting to new config --- lib/rest_easy.rb | 7 +++++ lib/rest_easy/settings.rb | 2 +- spec/rest_easy/resource/conversions_spec.rb | 35 +++++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/rest_easy.rb b/lib/rest_easy.rb index 8f19cf4..880039b 100644 --- a/lib/rest_easy.rb +++ b/lib/rest_easy.rb @@ -83,6 +83,13 @@ def configure(&block) else yield self::Settings.config end + + # BC: propagate deprecated attribute_convention to conversions + ac = self::Settings.config.attribute_convention + if ac + warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead" + self::Settings.config.conversions.json_attributes = ac + end end end diff --git a/lib/rest_easy/settings.rb b/lib/rest_easy/settings.rb index 038253f..59ff806 100644 --- a/lib/rest_easy/settings.rb +++ b/lib/rest_easy/settings.rb @@ -9,7 +9,7 @@ class Settings setting :base_url, default: "https://example.com", reader: true setting :max_retries, default: 3, reader: true setting :authentication, default: Auth::Null.new, reader: true - setting :attribute_convention, default: :PascalCase, reader: true # deprecated, kept for BC + setting :attribute_convention # deprecated — propagated to conversions.json_attributes in configure setting :conversions do setting :query_parameters, default: :snake_case, reader: true diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index 473ebc1..d9be87c 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -248,6 +248,41 @@ class MixedApi::Item < RestEasy::Resource # ── Backwards compatibility ───────────────────────────────────────── describe "backwards compatibility" do + describe "module-level attribute_convention" do + before(:all) do + module BCModuleApi + extend RestEasy + + configure do |config| + config.attribute_convention = :PascalCase + end + end + + class BCModuleApi::Invoice < RestEasy::Resource + attr :customer_name, String + end + end + + after(:all) do + Object.send(:remove_const, :BCModuleApi) + end + + it "propagates to conversions.json_attributes" do + conv = BCModuleApi::Invoice.resolved_conversions + expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + end + + it "defaults query_parameters to snake_case" do + conv = BCModuleApi::Invoice.resolved_conversions + expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) + end + + it "parses with the propagated convention" do + instance = BCModuleApi::Invoice.parse({ "CustomerName" => "Acme" }) + expect(instance.customer_name).to eq("Acme") + end + end + describe "resource-level attribute_convention" do it "sets json_attributes and emits a deprecation warning" do resource_class = Class.new(RestEasy::Resource) From f540047d71eb62cb41814c3247580b71ea318f95 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:58:51 +0200 Subject: [PATCH 5/6] Simplifies the memoized converter functions --- lib/rest_easy/conventions.rb | 2 - lib/rest_easy/resource.rb | 35 ++++++++------- spec/rest_easy/resource/conversions_spec.rb | 48 ++++++++------------- 3 files changed, 34 insertions(+), 51 deletions(-) diff --git a/lib/rest_easy/conventions.rb b/lib/rest_easy/conventions.rb index 13bea6d..f0b3307 100644 --- a/lib/rest_easy/conventions.rb +++ b/lib/rest_easy/conventions.rb @@ -56,8 +56,6 @@ def serialise(model_name) snake_case: SnakeCase.new }.freeze - ConventionPair = Struct.new(:query_parameters, :json_attributes, keyword_init: true) - def self.resolve(convention) case convention when Symbol diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 37da290..1ae869a 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -150,19 +150,18 @@ def metadata(**kwargs) # -- conversions --------------------------------------------------- - def resolved_conversions - @resolved_conversions ||= begin - qp = config.conversions.query_parameters || - parent&.config&.conversions&.query_parameters - - ja = config.conversions.json_attributes || - parent&.config&.conversions&.json_attributes - - Conventions::ConventionPair.new( - query_parameters: Conventions.resolve(qp), - json_attributes: Conventions.resolve(ja) - ) - end + def json_attribute_converter + @json_attribute_converter ||= Conventions.resolve( + config.conversions.json_attributes || + parent&.config&.conversions&.json_attributes + ) + end + + def query_parameter_converter + @query_parameter_converter ||= Conventions.resolve( + config.conversions.query_parameters || + parent&.config&.conversions&.query_parameters + ) end # -- attribute_convention (deprecated) ------------------------------- @@ -171,9 +170,9 @@ def attribute_convention(value = nil) if value warn "RestEasy: attribute_convention is deprecated, use `configure { conversions.json_attributes = #{value.inspect} }` instead" config.conversions.json_attributes = value - @resolved_conversions = nil # bust memoization + @json_attribute_converter = nil # bust memoization end - resolved_conversions.json_attributes + json_attribute_converter end private @@ -212,7 +211,7 @@ def attr(name_or_mapping, *args, &block) attribute_api_name = name_or_mapping[1].to_s else attribute_model_name = name_or_mapping.to_sym - attribute_api_name = resolved_conversions.json_attributes.serialise(attribute_model_name) + attribute_api_name = json_attribute_converter.serialise(attribute_model_name) end # Extract type (non-Symbol), flags (Symbols), and optional mapper object @@ -481,7 +480,7 @@ def delete(id) def get(path:, params: {}, headers: {}) if params.any? - conv = resolved_conversions.query_parameters + conv = query_parameter_converter params = params.transform_keys { |k| conv.serialise(k) } end parent.get(path:, params:, headers:) @@ -603,7 +602,7 @@ def serialise serialised = attr_def.serialise_value(value) if serialised.is_a?(::Array) # Array return: zip with source field API names - convention = klass.resolved_conversions.json_attributes + convention = klass.json_attribute_converter attr_def.source_fields.zip(serialised).each do |field_name, field_value| api_key = convention.serialise(field_name) result[api_key] = field_value diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index d9be87c..9b8c11b 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -39,13 +39,11 @@ class ConvTestApi::Invoice < RestEasy::Resource end it "resolves json_attributes from module config" do - conv = ConvTestApi::Invoice.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + expect(ConvTestApi::Invoice.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) end it "resolves query_parameters from module config" do - conv = ConvTestApi::Invoice.resolved_conversions - expect(conv.query_parameters).to be_a(RestEasy::Conventions::CamelCase) + expect(ConvTestApi::Invoice.query_parameter_converter).to be_a(RestEasy::Conventions::CamelCase) end it "parses API data using json_attributes convention" do @@ -125,15 +123,13 @@ class ResOverrideApi::Custom < ResOverrideApi::Base end it "inherits module-level convention when not overridden" do - conv = ResOverrideApi::Standard.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) - expect(conv.query_parameters).to be_a(RestEasy::Conventions::CamelCase) + expect(ResOverrideApi::Standard.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) + expect(ResOverrideApi::Standard.query_parameter_converter).to be_a(RestEasy::Conventions::CamelCase) end it "uses resource-level convention when overridden" do - conv = ResOverrideApi::Custom.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) - expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + expect(ResOverrideApi::Custom.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) + expect(ResOverrideApi::Custom.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end it "parses with inherited convention" do @@ -147,11 +143,8 @@ class ResOverrideApi::Custom < ResOverrideApi::Base end it "does not affect sibling resources" do - standard_conv = ResOverrideApi::Standard.resolved_conversions - custom_conv = ResOverrideApi::Custom.resolved_conversions - - expect(standard_conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) - expect(custom_conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + expect(ResOverrideApi::Standard.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) + expect(ResOverrideApi::Custom.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) end end @@ -183,13 +176,11 @@ class PartialApi::Resource < RestEasy::Resource end it "uses overridden query_parameters" do - conv = PartialApi::Resource.resolved_conversions - expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + expect(PartialApi::Resource.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end it "inherits json_attributes from module" do - conv = PartialApi::Resource.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + expect(PartialApi::Resource.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) end end @@ -217,9 +208,8 @@ class MixedApi::Item < RestEasy::Resource end it "uses different conventions for attributes and parameters" do - conv = MixedApi::Item.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::CamelCase) - expect(conv.query_parameters).to be_a(RestEasy::Conventions::PascalCase) + expect(MixedApi::Item.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) + expect(MixedApi::Item.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end it "serialises attributes as camelCase" do @@ -268,13 +258,11 @@ class BCModuleApi::Invoice < RestEasy::Resource end it "propagates to conversions.json_attributes" do - conv = BCModuleApi::Invoice.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::PascalCase) + expect(BCModuleApi::Invoice.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) end it "defaults query_parameters to snake_case" do - conv = BCModuleApi::Invoice.resolved_conversions - expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) + expect(BCModuleApi::Invoice.query_parameter_converter).to be_a(RestEasy::Conventions::SnakeCase) end it "parses with the propagated convention" do @@ -291,7 +279,7 @@ class BCModuleApi::Invoice < RestEasy::Resource resource_class.attribute_convention :camelCase }.to output(/deprecated/).to_stderr - expect(resource_class.resolved_conversions.json_attributes).to be_a(RestEasy::Conventions::CamelCase) + expect(resource_class.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) end it "still works as a getter" do @@ -325,13 +313,11 @@ class DefaultApi::Thing < RestEasy::Resource end it "defaults json_attributes to snake_case" do - conv = DefaultApi::Thing.resolved_conversions - expect(conv.json_attributes).to be_a(RestEasy::Conventions::SnakeCase) + expect(DefaultApi::Thing.json_attribute_converter).to be_a(RestEasy::Conventions::SnakeCase) end it "defaults query_parameters to snake_case" do - conv = DefaultApi::Thing.resolved_conversions - expect(conv.query_parameters).to be_a(RestEasy::Conventions::SnakeCase) + expect(DefaultApi::Thing.query_parameter_converter).to be_a(RestEasy::Conventions::SnakeCase) end end end From 8991cd7e246b9d1a2beb9a6ec0a86e37bf760301 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 17:03:40 +0200 Subject: [PATCH 6/6] Simplifies query param conversion logic --- lib/rest_easy/resource.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 1ae869a..9ce8258 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -479,10 +479,7 @@ def delete(id) # HTTP primitives — delegate to the parent API module's connection def get(path:, params: {}, headers: {}) - if params.any? - conv = query_parameter_converter - params = params.transform_keys { |k| conv.serialise(k) } - end + params.transform_keys! { |k| query_parameter_converter.serialise(k) } parent.get(path:, params:, headers:) end