Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Changelog

## [1.1.0] - 2026-03-25

### Added

- **`conversions` configuration** with independent `query_parameters` and `json_attributes` sub-keys. This allows APIs that use different naming conventions for query parameters vs JSON body attributes to be configured correctly:

```ruby
module MyAPI
extend RestEasy

configure do
conversions.json_attributes = :camelCase
conversions.query_parameters = :PascalCase
end
end
```

- **Automatic query parameter key transformation.** `Resource.get` now transforms parameter keys according to the `query_parameters` convention before sending the request. This removes the need for manual `transform_keys` calls in consuming gems.

- `conversions` can be overridden per Resource class, with inheritance falling back to the parent API module configuration.

### Deprecated

- **`attribute_convention`** is deprecated in favour of `conversions.json_attributes`. The old setting continues to work and is respected as a fallback, but emits a deprecation warning when used at the Resource level. Module-level `attribute_convention` is silently supported for backwards compatibility.

Comment on lines +24 to +27
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog says module-level attribute_convention is "silently supported" for backwards compatibility, but RestEasy.configure currently emits a deprecation warning when it sees attribute_convention set. Either update the changelog wording or adjust the implementation to avoid warning at the module level (and only warn for resource-level usage).

Copilot uses AI. Check for mistakes.
## [1.0.0]

Initial release.
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rest-easy (0.1.0)
rest-easy (1.1.0)
dry-configurable (~> 0.14)
dry-inflector (~> 0.2.1)
dry-types (~> 1.2)
Expand Down
41 changes: 29 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ module Acme
configure do
base_url "https://api.acme.com/v1"
authentication RestEasy::Auth::PSK.new(api_key: ENV["ACME_API_KEY"])
attribute_convention :PascalCase
conversions.json_attributes = :PascalCase
end
end

Expand Down Expand Up @@ -85,19 +85,21 @@ module Fortnox
base_url "https://api.fortnox.se/3"
max_retries 3
authentication RestEasy::Auth::PSK.new(api_key: ENV["FORTNOX_KEY"])
attribute_convention :PascalCase
conversions.json_attributes = :PascalCase
conversions.query_parameters = :PascalCase
end
end
```

### Available settings

| Setting | Default | Description |
|------------------------|----------------------------|------------------------------------------|
| `base_url` | `"https://example.com"` | Base URL for all requests |
| `max_retries` | `3` | Retry count on request failure |
| `authentication` | `Auth::Null.new` | Authentication strategy |
| `attribute_convention` | `:PascalCase` | Naming convention for API field mapping |
| Setting | Default | Description |
|----------------------------------|----------------------------|---------------------------------------------------|
| `base_url` | `"https://example.com"` | Base URL for all requests |
| `max_retries` | `3` | Retry count on request failure |
| `authentication` | `Auth::Null.new` | Authentication strategy |
| `conversions.json_attributes` | `:snake_case` | Naming convention for JSON response/request fields|
| `conversions.query_parameters` | `:snake_case` | Naming convention for query parameter keys |

### Faraday middleware

Expand Down Expand Up @@ -189,20 +191,35 @@ 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 |
|---------------|--------------------|----------------------|
| `:PascalCase` | `:document_number` | `"DocumentNumber"` |
| `: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
Expand Down Expand Up @@ -735,7 +752,7 @@ module MyAPI
base_url "https://api.example.com/v1"
max_retries 3
authentication RestEasy::Auth::PSK.new(api_key: ENV["MY_API_KEY"])
attribute_convention :PascalCase
conversions.json_attributes = :PascalCase
end
end
```
Expand Down
7 changes: 7 additions & 0 deletions lib/rest_easy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +89 to +91
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BC propagation always warns and unconditionally overwrites conversions.json_attributes whenever attribute_convention is present. This makes it hard to migrate gradually (a user could set conversions.json_attributes and still get it overridden) and can emit repeated warnings across multiple configure calls. Consider only propagating when conversions.json_attributes is unset, and/or clearing attribute_convention after propagation, and align warning behavior with the intended compatibility policy.

Suggested change
if ac
warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead"
self::Settings.config.conversions.json_attributes = ac
conv = self::Settings.config.conversions
if ac && conv.json_attributes.nil?
warn "RestEasy: attribute_convention is deprecated, use `conversions.json_attributes = #{ac.inspect}` instead"
conv.json_attributes = ac

Copilot uses AI. Check for mistakes.
end
end
end

Expand Down
37 changes: 29 additions & 8 deletions lib/rest_easy/resource.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -143,16 +148,31 @@ def metadata(**kwargs)
end
end

# -- attribute_convention ------------------------------------------
# -- conversions ---------------------------------------------------
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happened with small single responsibility classes? This class is huge. Also I'm not a fan of these comments. If this is a separate concern in this class then it might be a sign that we can extract it to a separate class that we can test in isolation 😄


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
)
Comment on lines +154 to +164
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

json_attribute_converter/query_parameter_converter are memoized, but configure can be called multiple times (including after class definition) and parent module config can also change. Once memoized, changes to config.conversions.* (or inherited values) won’t be picked up, leading to stale conversion behavior. Consider either removing memoization or memoizing based on the effective convention value and resetting when configure updates conversions.

Also, if this Resource has no RestEasy parent module and no per-resource conversions set, both methods call Conventions.resolve(nil) (which returns nil), and later callers like attr will crash on nil.serialise. Add an explicit fallback (e.g. to :snake_case/previous default) when neither local nor parent config provides a convention.

Suggested change
@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
)
convention =
config.conversions.json_attributes ||
parent&.config&.conversions&.json_attributes ||
:snake_case
Conventions.resolve(convention)
end
def query_parameter_converter
convention =
config.conversions.query_parameters ||
parent&.config&.conversions&.query_parameters ||
:snake_case
Conventions.resolve(convention)

Copilot uses AI. Check for mistakes.
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
@json_attribute_converter = nil # bust memoization
end
json_attribute_converter
end

private
Expand Down Expand Up @@ -191,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 = attribute_convention.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
Expand Down Expand Up @@ -459,6 +479,7 @@ 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:)
Comment on lines +482 to 483
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

params.transform_keys! mutates the caller-provided params hash and will raise if the hash is frozen. This can cause surprising side effects for callers reusing a params hash across requests. Prefer a non-mutating transform (e.g., build a new hash and pass that to parent.get) and leave the input untouched.

Suggested change
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:)

Copilot uses AI. Check for mistakes.
end

Expand Down Expand Up @@ -578,7 +599,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.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
Expand Down
7 changes: 6 additions & 1 deletion lib/rest_easy/settings.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 # 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
Comment on lines +12 to +16
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the previous default attribute_convention (was :PascalCase) and sets both conversions defaults to :snake_case. That’s a breaking behavioral change for consumers who relied on the old default mapping without explicitly configuring it (and it also interacts with Resource classes that have no parent module). If backwards compatibility is a goal for a 1.x minor bump, consider keeping the old default for conversions.json_attributes (and possibly query_parameters), or bumping the major version and calling out the default change prominently in docs/changelog.

Suggested change
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 :attribute_convention, default: :PascalCase # deprecated — propagated to conversions.json_attributes in configure
setting :conversions do
setting :query_parameters, default: :snake_case, reader: true
setting :json_attributes, default: :PascalCase, reader: true

Copilot uses AI. Check for mistakes.
end
end
end
2 changes: 1 addition & 1 deletion lib/rest_easy/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module RestEasy
VERSION = "1.0.0"
VERSION = "1.1.0"
end
Loading
Loading