Skip to content
Merged
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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# 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 — 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.

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.
### 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]

Initial release.
3 changes: 1 addition & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
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)
faraday (~> 2.0)
zeitwerk (~> 2.6)
Expand Down
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
attribute_convention :PascalCase
end
end

Expand Down Expand Up @@ -87,19 +86,19 @@ 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
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` | `:PascalCase` | Naming convention for JSON response/request fields|
| `conversions.query_parameters` | `:PascalCase` | Naming convention for query parameter keys |

### Faraday middleware

Expand Down Expand Up @@ -191,20 +190,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. Both default to `:PascalCase`:

| 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 @@ -737,7 +751,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
12 changes: 11 additions & 1 deletion lib/rest_easy.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
# frozen_string_literal: true

require "rubygems"
require "dry/inflector"
require "dry/types"
require "faraday"
require "zeitwerk"
Expand Down Expand Up @@ -83,6 +82,17 @@ def configure(&block)
else
yield self::Settings.config
end

# 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"
self::Settings.config.conversions.json_attributes = ac
Comment on lines +90 to +93
@_propagated_attribute_convention = ac
end
end
end

Expand Down
2 changes: 2 additions & 0 deletions lib/rest_easy/conventions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ def serialise(model_name)
snake_case: SnakeCase.new
}.freeze

DEFAULT = :PascalCase

def self.resolve(convention)
case convention
when Symbol
Expand Down
44 changes: 33 additions & 11 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,32 @@ 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
Conventions.resolve(
config.conversions.json_attributes ||
parent&.config&.conversions&.json_attributes ||
Conventions::DEFAULT
)
end

def query_parameter_converter
Conventions.resolve(
config.conversions.query_parameters ||
parent&.config&.conversions&.query_parameters ||
Conventions::DEFAULT
)
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
end
json_attribute_converter
end

private
Expand Down Expand Up @@ -191,7 +212,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,7 +480,8 @@ def delete(id)
# HTTP primitives — delegate to the parent API module's connection

def get(path:, params: {}, headers: {})
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: {})
Expand Down Expand Up @@ -578,7 +600,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 Expand Up @@ -655,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]
Expand All @@ -678,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) }
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: Conventions::DEFAULT, reader: true
setting :json_attributes, default: Conventions::DEFAULT, reader: true
end
end
end
1 change: 0 additions & 1 deletion rest-easy.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
Loading