From 8b4f8d51129e500c9b9a4d8fe1178d6fe48c234e Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:11:45 +0200 Subject: [PATCH 01/13] 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 +- spec/rest_easy/resource/conversions_spec.rb | 339 ++++++++++++++++++++ spec/rest_easy/resource_spec.rb | 133 ++++++++ 7 files changed, 547 insertions(+), 10 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..457ad4a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +## [Unreleased] + +### 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..f98a0cd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - rest-easy (0.1.0) + rest-easy (1.0.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/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 e2a113fe695a0538fee9caa4eafaca85673a84ac Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:16:05 +0200 Subject: [PATCH 02/13] 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 244f80b..02af1c9 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,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 @@ -87,19 +87,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 @@ -191,7 +193,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 | |---------------|--------------------|----------------------| @@ -199,12 +201,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 @@ -737,7 +754,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 137c784140e204d8f23e9b6c4b9814a7cfc89bce Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:22:06 +0200 Subject: [PATCH 03/13] 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 df5b93c1c23b8c657a507c07942908217e9e15e9 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:29:44 +0200 Subject: [PATCH 04/13] 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 493f69cfc91d71fabb7f98e60a323894590ff781 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 16:58:51 +0200 Subject: [PATCH 05/13] 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 35d8512c143fdf2195ab1f3d32857aa34e88a562 Mon Sep 17 00:00:00 2001 From: Jonas Schubert Erlandsson Date: Wed, 25 Mar 2026 17:03:40 +0200 Subject: [PATCH 06/13] 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 From 545bf980980dcea0ac672d2a0a3fc1717e350f47 Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Thu, 14 May 2026 09:42:02 +0200 Subject: [PATCH 07/13] Drops the unused dry-inflector dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gem never references Dry::Inflector — Zeitwerk's own inflector handles the .inflect call. The pin to ~> 0.2.1 also conflicted with dry-types >= 1.7, which requires dry-inflector ~> 1.0. --- CHANGELOG.md | 4 ++++ Gemfile.lock | 1 - lib/rest_easy.rb | 1 - rest-easy.gemspec | 1 - 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 457ad4a..2597d9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ - **`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. +### Removed + +- **`dry-inflector` runtime dependency.** The gem never used `Dry::Inflector` — Zeitwerk's own inflector is the only one used. + ## [1.0.0] Initial release. diff --git a/Gemfile.lock b/Gemfile.lock index f98a0cd..766f391 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,6 @@ PATH specs: rest-easy (1.0.0) dry-configurable (~> 0.14) - dry-inflector (~> 0.2.1) dry-types (~> 1.2) faraday (~> 2.0) zeitwerk (~> 2.6) diff --git a/lib/rest_easy.rb b/lib/rest_easy.rb index 880039b..220c4cd 100644 --- a/lib/rest_easy.rb +++ b/lib/rest_easy.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "rubygems" -require "dry/inflector" require "dry/types" require "faraday" require "zeitwerk" diff --git a/rest-easy.gemspec b/rest-easy.gemspec index a4c8952..0be08bb 100644 --- a/rest-easy.gemspec +++ b/rest-easy.gemspec @@ -24,7 +24,6 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "dry-types", "~> 1.2" spec.add_dependency "zeitwerk", "~> 2.6" - spec.add_dependency "dry-inflector", "~> 0.2.1" spec.add_dependency "dry-configurable", "~> 0.14" spec.add_dependency "faraday", "~> 2.0" From bab470f483ee821677eabfb8ecf048b61a64ba85 Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Thu, 14 May 2026 10:40:53 +0200 Subject: [PATCH 08/13] Fixes Resource.get mutating caller-provided params hash Replaces transform_keys! with the non-mutating transform_keys so callers can reuse or freeze the params hash they pass to get. --- lib/rest_easy/resource.rb | 4 +-- spec/rest_easy/resource/conversions_spec.rb | 27 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 9ce8258..16eb8d8 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -479,8 +479,8 @@ def delete(id) # HTTP primitives — delegate to the parent API module's connection def get(path:, params: {}, headers: {}) - params.transform_keys! { |k| query_parameter_converter.serialise(k) } - parent.get(path:, params:, headers:) + converted_params = params.transform_keys { |k| query_parameter_converter.serialise(k) } + parent.get(path:, params: converted_params, headers:) end def post(path:, body: nil, headers: {}) diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index 9b8c11b..8d02c96 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -86,6 +86,33 @@ class ConvTestApi::Invoice < RestEasy::Resource expect(captured_params).to include("customerName" => "Test", "sortOrder" => "asc") end + + it "does not mutate the caller-provided params hash" do + setup_test_connection(ConvTestApi) do |stub| + stub.get("/invoices") do + [200, { "Content-Type" => "application/json" }, "[]"] + end + end + + params = { customer_name: "Test" } + ConvTestApi::Invoice.get(path: "invoices", params: params) + + expect(params).to eq(customer_name: "Test") + end + + it "accepts a frozen params hash without raising" do + setup_test_connection(ConvTestApi) do |stub| + stub.get("/invoices") do + [200, { "Content-Type" => "application/json" }, "[]"] + end + end + + params = { customer_name: "Test" }.freeze + + expect { + ConvTestApi::Invoice.get(path: "invoices", params: params) + }.not_to raise_error + end end # ── Resource-level override ───────────────────────────────────────── From 786adaab61d7c45132c3933a9b3f3d9f59c91a74 Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Fri, 15 May 2026 08:01:46 +0200 Subject: [PATCH 09/13] Stops re-propagating attribute_convention to conversions Memoises the last-propagated value so subsequent configure calls don't clobber an explicitly set conversions.json_attributes (and don't repeat the deprecation warning). --- lib/rest_easy.rb | 7 ++-- spec/rest_easy/resource/conversions_spec.rb | 40 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/lib/rest_easy.rb b/lib/rest_easy.rb index 220c4cd..99e7ff9 100644 --- a/lib/rest_easy.rb +++ b/lib/rest_easy.rb @@ -83,11 +83,14 @@ def configure(&block) yield self::Settings.config end - # BC: propagate deprecated attribute_convention to conversions + # BC: propagate deprecated attribute_convention to conversions, but + # only on changes — so repeated `configure` calls don't re-warn and + # don't clobber a `conversions.json_attributes` set in a later call. ac = self::Settings.config.attribute_convention - if ac + if ac && @_propagated_attribute_convention != ac warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead" self::Settings.config.conversions.json_attributes = ac + @_propagated_attribute_convention = ac end end end diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index 8d02c96..ddb94bb 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -298,6 +298,46 @@ class BCModuleApi::Invoice < RestEasy::Resource end end + describe "module-level attribute_convention propagation" do + it "warns only once across repeated configure calls" do + module BCRepeatApi + extend RestEasy + end + + original_stderr = $stderr + $stderr = StringIO.new + output = nil + begin + BCRepeatApi.configure { |c| c.attribute_convention = :PascalCase } + BCRepeatApi.configure { |c| c.base_url = "https://api.example.com" } + ensure + output = $stderr.string + $stderr = original_stderr + Object.send(:remove_const, :BCRepeatApi) + end + + expect(output.scan(/attribute_convention is deprecated/).length).to eq(1) + end + + it "does not overwrite a conversions.json_attributes set after attribute_convention" do + module BCOverrideApi + extend RestEasy + end + + original_stderr = $stderr + $stderr = StringIO.new + begin + BCOverrideApi.configure { |c| c.attribute_convention = :PascalCase } + BCOverrideApi.configure { conversions.json_attributes = :camelCase } + ensure + $stderr = original_stderr + end + + expect(BCOverrideApi::Settings.config.conversions.json_attributes).to eq(:camelCase) + Object.send(:remove_const, :BCOverrideApi) + 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 96abb3afd982e9c521dd3b3a2cee4b3a3590fa10 Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Fri, 15 May 2026 08:08:11 +0200 Subject: [PATCH 10/13] Preserves PascalCase as the default conversion Restores the 1.0.0 default of :PascalCase (formerly via attribute_convention) for both json_attributes and query_parameters. Keeps the two settings symmetric so upgrading users see no behavioural change without an explicit major version bump. --- README.md | 6 +++--- lib/rest_easy/settings.rb | 4 ++-- spec/rest_easy/resource/conversions_spec.rb | 12 ++++++------ 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 02af1c9..0aaa8e2 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,8 @@ end | `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 | +| `conversions.json_attributes` | `:PascalCase` | Naming convention for JSON response/request fields| +| `conversions.query_parameters` | `:PascalCase` | Naming convention for query parameter keys | ### Faraday middleware @@ -193,7 +193,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. The `conversions` config controls this independently for JSON attributes and query parameters: +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. Both default to `:PascalCase`: | Convention | Ruby attr | API field | |---------------|--------------------|----------------------| diff --git a/lib/rest_easy/settings.rb b/lib/rest_easy/settings.rb index 59ff806..fbb70e4 100644 --- a/lib/rest_easy/settings.rb +++ b/lib/rest_easy/settings.rb @@ -12,8 +12,8 @@ class Settings setting :attribute_convention # deprecated — propagated to conversions.json_attributes in configure setting :conversions do - setting :query_parameters, default: :snake_case, reader: true - setting :json_attributes, default: :snake_case, reader: true + setting :query_parameters, default: :PascalCase, reader: true + setting :json_attributes, default: :PascalCase, reader: true end end end diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index ddb94bb..086f145 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -288,8 +288,8 @@ class BCModuleApi::Invoice < RestEasy::Resource expect(BCModuleApi::Invoice.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) end - it "defaults query_parameters to snake_case" do - expect(BCModuleApi::Invoice.query_parameter_converter).to be_a(RestEasy::Conventions::SnakeCase) + it "defaults query_parameters to PascalCase" do + expect(BCModuleApi::Invoice.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end it "parses with the propagated convention" do @@ -379,12 +379,12 @@ class DefaultApi::Thing < RestEasy::Resource Object.send(:remove_const, :DefaultApi) end - it "defaults json_attributes to snake_case" do - expect(DefaultApi::Thing.json_attribute_converter).to be_a(RestEasy::Conventions::SnakeCase) + it "defaults json_attributes to PascalCase (matches 1.0.0 attribute_convention default)" do + expect(DefaultApi::Thing.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) end - it "defaults query_parameters to snake_case" do - expect(DefaultApi::Thing.query_parameter_converter).to be_a(RestEasy::Conventions::SnakeCase) + it "defaults query_parameters to PascalCase" do + expect(DefaultApi::Thing.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end end end From 42fdd74c970487537b5ccaa35a761fb4c69b83fd Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Fri, 15 May 2026 08:09:58 +0200 Subject: [PATCH 11/13] Reconciles changelog wording on attribute_convention The implementation emits a deprecation warning at the module level too, not just at the resource level. Update the wording to reflect that. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2597d9d..e522e19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ ### 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. +- **`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. ### Removed From df85a5791a9b0dd02c3a880dc431fa1bd974262e Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Fri, 15 May 2026 08:17:21 +0200 Subject: [PATCH 12/13] Drops converter memoisation and adds a parent-less fallback Memoisation made later configure calls invisible to resources that had already resolved a converter. Resolving on every call is cheap (a frozen hash lookup) and removes the staleness risk entirely. Adds an explicit :PascalCase fallback so a resource with no parent RestEasy module and no per-resource conversions configured still resolves to a usable converter instead of nil. --- lib/rest_easy/resource.rb | 11 +++--- spec/rest_easy/resource/conversions_spec.rb | 41 +++++++++++++++++++++ 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index 16eb8d8..f372d6b 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -151,16 +151,18 @@ def metadata(**kwargs) # -- conversions --------------------------------------------------- def json_attribute_converter - @json_attribute_converter ||= Conventions.resolve( + Conventions.resolve( config.conversions.json_attributes || - parent&.config&.conversions&.json_attributes + parent&.config&.conversions&.json_attributes || + :PascalCase ) end def query_parameter_converter - @query_parameter_converter ||= Conventions.resolve( + Conventions.resolve( config.conversions.query_parameters || - parent&.config&.conversions&.query_parameters + parent&.config&.conversions&.query_parameters || + :PascalCase ) end @@ -170,7 +172,6 @@ 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 - @json_attribute_converter = nil # bust memoization end json_attribute_converter end diff --git a/spec/rest_easy/resource/conversions_spec.rb b/spec/rest_easy/resource/conversions_spec.rb index 086f145..ddf379b 100644 --- a/spec/rest_easy/resource/conversions_spec.rb +++ b/spec/rest_easy/resource/conversions_spec.rb @@ -386,5 +386,46 @@ class DefaultApi::Thing < RestEasy::Resource it "defaults query_parameters to PascalCase" do expect(DefaultApi::Thing.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) end + + it "falls back to PascalCase for a resource with no parent module" do + orphan = Class.new(RestEasy::Resource) + + expect(orphan.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) + expect(orphan.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) + end + end + + # ── Dynamic reconfiguration ───────────────────────────────────────── + + describe "dynamic reconfiguration" do + it "picks up json_attributes changes made after the converter is first read" do + module ReconfigJsonApi + extend RestEasy + end + class ReconfigJsonApi::Foo < RestEasy::Resource; end + + expect(ReconfigJsonApi::Foo.json_attribute_converter).to be_a(RestEasy::Conventions::PascalCase) + + ReconfigJsonApi.configure { conversions.json_attributes = :camelCase } + + expect(ReconfigJsonApi::Foo.json_attribute_converter).to be_a(RestEasy::Conventions::CamelCase) + ensure + Object.send(:remove_const, :ReconfigJsonApi) if defined?(ReconfigJsonApi) + end + + it "picks up query_parameters changes made after the converter is first read" do + module ReconfigQueryApi + extend RestEasy + end + class ReconfigQueryApi::Foo < RestEasy::Resource; end + + expect(ReconfigQueryApi::Foo.query_parameter_converter).to be_a(RestEasy::Conventions::PascalCase) + + ReconfigQueryApi.configure { conversions.query_parameters = :snake_case } + + expect(ReconfigQueryApi::Foo.query_parameter_converter).to be_a(RestEasy::Conventions::SnakeCase) + ensure + Object.send(:remove_const, :ReconfigQueryApi) if defined?(ReconfigQueryApi) + end end end From 5437d3adef924ecbf94f99b3db88b7efb138ee6c Mon Sep 17 00:00:00 2001 From: Hannes Elvemyr Date: Fri, 15 May 2026 08:46:04 +0200 Subject: [PATCH 13/13] Cleans up after the conversions refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes 64 now-redundant `configure { conversions.json_attributes = :PascalCase }` setters from the resource spec — they only existed to preserve PascalCase behaviour while the default was briefly :snake_case and became no-ops once the default returned to :PascalCase. Replaces two internal uses of the deprecated klass.attribute_convention with klass.json_attribute_converter, factors the :PascalCase literal into a Conventions::DEFAULT constant, spells out the "BC" comment shorthand, drops the now-default conversions setters from the README examples, and notes the loss of the attribute_convention default in the changelog. --- CHANGELOG.md | 1 + README.md | 3 - lib/rest_easy.rb | 7 +- lib/rest_easy/conventions.rb | 2 + lib/rest_easy/resource.rb | 8 +- lib/rest_easy/settings.rb | 4 +- spec/rest_easy/resource_spec.rb | 128 -------------------------------- 7 files changed, 13 insertions(+), 140 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e522e19..743e86e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ ### Removed - **`dry-inflector` runtime dependency.** The gem never used `Dry::Inflector` — Zeitwerk's own inflector is the only one used. +- **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`). ## [1.0.0] diff --git a/README.md b/README.md index 0aaa8e2..5909053 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,6 @@ module Acme configure do base_url "https://api.acme.com/v1" authentication RestEasy::Auth::PSK.new(api_key: ENV["ACME_API_KEY"]) - conversions.json_attributes = :PascalCase end end @@ -87,8 +86,6 @@ module Fortnox base_url "https://api.fortnox.se/3" max_retries 3 authentication RestEasy::Auth::PSK.new(api_key: ENV["FORTNOX_KEY"]) - conversions.json_attributes = :PascalCase - conversions.query_parameters = :PascalCase end end ``` diff --git a/lib/rest_easy.rb b/lib/rest_easy.rb index 99e7ff9..16a179c 100644 --- a/lib/rest_easy.rb +++ b/lib/rest_easy.rb @@ -83,9 +83,10 @@ def configure(&block) yield self::Settings.config end - # BC: propagate deprecated attribute_convention to conversions, but - # only on changes — so repeated `configure` calls don't re-warn and - # don't clobber a `conversions.json_attributes` set in a later call. + # Backwards compatibility: propagate the deprecated attribute_convention + # to conversions, but only on changes — so repeated `configure` calls + # don't re-warn and don't clobber a `conversions.json_attributes` set in + # a later call. ac = self::Settings.config.attribute_convention if ac && @_propagated_attribute_convention != ac warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead" diff --git a/lib/rest_easy/conventions.rb b/lib/rest_easy/conventions.rb index f0b3307..09afef6 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 + DEFAULT = :PascalCase + def self.resolve(convention) case convention when Symbol diff --git a/lib/rest_easy/resource.rb b/lib/rest_easy/resource.rb index f372d6b..9d87d59 100644 --- a/lib/rest_easy/resource.rb +++ b/lib/rest_easy/resource.rb @@ -154,7 +154,7 @@ def json_attribute_converter Conventions.resolve( config.conversions.json_attributes || parent&.config&.conversions&.json_attributes || - :PascalCase + Conventions::DEFAULT ) end @@ -162,7 +162,7 @@ def query_parameter_converter Conventions.resolve( config.conversions.query_parameters || parent&.config&.conversions&.query_parameters || - :PascalCase + Conventions::DEFAULT ) end @@ -677,7 +677,7 @@ def init_from_api(api_data, extra_meta = {}) if attr_def.source_fields.any? # Source fields declared via block params: extract individual # values from api_data using convention, splat into parse block. - convention = klass.attribute_convention + convention = klass.json_attribute_converter raw_values = attr_def.source_fields.map do |field_name| api_key = convention.serialise(field_name) api_data[api_key] @@ -700,7 +700,7 @@ def init_from_api(api_data, extra_meta = {}) if config.debug # Warn about API fields that are neither declared attrs nor explicitly ignored - convention = klass.attribute_convention + convention = klass.json_attribute_converter known_api_keys = klass.all_attribute_definitions.values.flat_map do |ad| keys = [ad.api_name] ad.source_fields.each { |sf| keys << convention.serialise(sf) } diff --git a/lib/rest_easy/settings.rb b/lib/rest_easy/settings.rb index fbb70e4..941f62e 100644 --- a/lib/rest_easy/settings.rb +++ b/lib/rest_easy/settings.rb @@ -12,8 +12,8 @@ class Settings setting :attribute_convention # deprecated — propagated to conversions.json_attributes in configure setting :conversions do - setting :query_parameters, default: :PascalCase, reader: true - setting :json_attributes, default: :PascalCase, reader: true + setting :query_parameters, default: Conventions::DEFAULT, reader: true + setting :json_attributes, default: Conventions::DEFAULT, reader: true end end end diff --git a/spec/rest_easy/resource_spec.rb b/spec/rest_easy/resource_spec.rb index 40ee892..6df048a 100644 --- a/spec/rest_easy/resource_spec.rb +++ b/spec/rest_easy/resource_spec.rb @@ -25,8 +25,6 @@ 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 @@ -43,8 +41,6 @@ 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 @@ -55,8 +51,6 @@ 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 @@ -67,8 +61,6 @@ 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 @@ -80,8 +72,6 @@ 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 @@ -93,8 +83,6 @@ module TestApi it "inherits metadata from parent resource" do parent = Class.new(described_class) do - configure { conversions.json_attributes = :PascalCase } - metadata partial: true end @@ -110,8 +98,6 @@ 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 @@ -125,8 +111,6 @@ 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 @@ -321,8 +305,6 @@ 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 @@ -340,8 +322,6 @@ 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 @@ -352,8 +332,6 @@ 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 @@ -363,8 +341,6 @@ 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 @@ -379,8 +355,6 @@ 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 @@ -421,8 +395,6 @@ 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 @@ -455,8 +427,6 @@ 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 @@ -503,8 +473,6 @@ 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 @@ -530,8 +498,6 @@ 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 @@ -542,8 +508,6 @@ 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 @@ -555,8 +519,6 @@ 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 @@ -569,8 +531,6 @@ 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 @@ -606,8 +566,6 @@ 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 @@ -638,8 +596,6 @@ 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 @@ -704,8 +660,6 @@ 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 @@ -721,8 +675,6 @@ 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 @@ -735,8 +687,6 @@ 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 @@ -751,8 +701,6 @@ 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 @@ -773,8 +721,6 @@ 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 @@ -792,8 +738,6 @@ 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 @@ -813,8 +757,6 @@ 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 @@ -832,8 +774,6 @@ 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| @@ -851,8 +791,6 @@ 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| @@ -869,8 +807,6 @@ 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| @@ -888,8 +824,6 @@ 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 @@ -912,8 +846,6 @@ 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 @@ -929,8 +861,6 @@ 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 @@ -959,8 +889,6 @@ 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 @@ -1021,8 +949,6 @@ 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 @@ -1059,8 +985,6 @@ 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 @@ -1108,8 +1032,6 @@ 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 @@ -1131,8 +1053,6 @@ 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 @@ -1149,8 +1069,6 @@ 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 @@ -1160,8 +1078,6 @@ 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 @@ -1171,8 +1087,6 @@ 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 @@ -1184,8 +1098,6 @@ 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 @@ -1196,8 +1108,6 @@ 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 @@ -1209,8 +1119,6 @@ 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 @@ -1223,8 +1131,6 @@ 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 @@ -1234,8 +1140,6 @@ 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 @@ -1246,8 +1150,6 @@ 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 @@ -1262,8 +1164,6 @@ 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 @@ -1274,8 +1174,6 @@ 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 @@ -1287,8 +1185,6 @@ 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 @@ -1299,8 +1195,6 @@ 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 @@ -1315,8 +1209,6 @@ 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 @@ -1334,8 +1226,6 @@ 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 @@ -1350,8 +1240,6 @@ 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 @@ -1373,8 +1261,6 @@ 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 @@ -1387,8 +1273,6 @@ 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 @@ -1401,8 +1285,6 @@ 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 @@ -1426,8 +1308,6 @@ 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 @@ -1442,8 +1322,6 @@ 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 @@ -1461,8 +1339,6 @@ 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" @@ -1479,8 +1355,6 @@ 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 @@ -1498,8 +1372,6 @@ 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