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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
* [#2827](https://github.com/ruby-grape/grape/pull/2827): Make the `cascade` DSL getter return the configured value (`cascade false` read back as `true`) - [@ericproulx](https://github.com/ericproulx).
* [#2829](https://github.com/ruby-grape/grape/pull/2829): Fix a cascading route handing over only to the last route registered for the path, making a middle version (3+ mounted versions with a catch-all) answer 406 - [@ericproulx](https://github.com/ericproulx).
* [#2826](https://github.com/ruby-grape/grape/pull/2826): Fix `api.version` not being set for the root route of a path-versioned API (`GET /v1`) - [@ericproulx](https://github.com/ericproulx).
* [#2844](https://github.com/ruby-grape/grape/pull/2844): Look an entity up by the presented object's own class before treating it as a collection, so `represent` is no longer skipped for models that respond to `#first` or `#klass` - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.3.5 (2026-07-30)
Expand Down
44 changes: 30 additions & 14 deletions lib/grape/dsl/entity.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,39 @@ def present(*args, root: nil, with: nil, **options)
# @param object [Object] the object to locate the Entity class for
# @return [Class] the located Entity class, or nil if none is found
def entity_class_for_obj(object)
klass = object_class(object)
entity_for_class(object.class) || entity_for_class(element_class(object))
end

private

# The class standing in for a collection or wrapper: ActiveRecord::Relation
# and the like expose #klass, anything else falls back to the class of its
# first element.
#
# Consulted only once the object's own class has come up empty, because
# both tests are duck-typed and plenty of single objects answer them —
# a Struct is Enumerable, so it responds to #first, and so does any model
# that includes Enumerable. Asking this first meant `represent Model,
# with: Entity` was silently ignored for those, the entity for the
# *element* type being looked up instead. Deferring it also keeps #first
# from being called at all when the object resolves on its own class.
#
# @param object [Object] the object to represent.
# @return [Class, nil]
def element_class(object)
return object.klass if object.respond_to?(:klass)

object.first.class if object.respond_to?(:first)
end

# @param klass [Class, nil] the class to look an entity up for.
# @return [Class, nil] the registered or conventionally named entity.
def entity_for_class(klass)
return if klass.nil?

representations = inheritable_setting.representations
if representations
potential = klass.ancestors.detect { |potential| representations.key?(potential) }
potential = klass.ancestors.detect { |ancestor| representations.key?(ancestor) }
return representations[potential] if potential && representations[potential]
end

Expand All @@ -65,18 +93,6 @@ def entity_class_for_obj(object)
entity if entity.respond_to?(:represent)
end

private

# Resolves the class used to look up the Entity for +object+.
# @param object [Object] the object to represent.
# @return [Class] the object's collection element class, wrapped class, or its own class.
def object_class(object)
return object.klass if object.respond_to?(:klass)
return object.first.class if object.respond_to?(:first)

object.class
end

# @param entity_class [Class] the entity class to use for representation.
# @param object [Object] the object to represent.
# @param options [Hash] additional options forwarded to the entity's `represent` call.
Expand Down
72 changes: 72 additions & 0 deletions spec/grape/api_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,78 @@
subject.represent represent_object, with: dummy_presenter_klass
expect(subject.inheritable_setting.representations).to eq(represent_object => dummy_presenter_klass)
end

# Both the collection tests are duck-typed, and plenty of single objects
# answer them, so a registered entity used to be skipped for those in favour
# of the entity for whatever #first returned.
context 'when the presented object also looks like a collection' do
let(:entity) do
Class.new do
def self.represent(object, **)
{ presented: object.class.name.to_s }
end
end
end

def present_with(api, model, object, entity)
api.format :json
api.represent model, with: entity
api.get('/') { present object }
end

it 'uses the entity registered for a Struct' do
model = Struct.new(:name)
present_with(subject, model, model.new('x'), entity)

get '/'
expect(JSON.parse(last_response.body)).to eq('presented' => model.name.to_s)
end

it 'uses the entity registered for an Enumerable model' do
model = Class.new do
include Enumerable

def each(&) = [1, 2].each(&)
end
present_with(subject, model, model.new, entity)

get '/'
expect(JSON.parse(last_response.body)).to eq('presented' => model.name.to_s)
end

it 'uses the entity registered for an object exposing #klass' do
model = Class.new do
def klass = String
end
present_with(subject, model, model.new, entity)

get '/'
expect(JSON.parse(last_response.body)).to eq('presented' => model.name.to_s)
end

it 'still resolves an array through its element class' do
model = Class.new
present_with(subject, model, [model.new], entity)

get '/'
expect(JSON.parse(last_response.body)).to eq('presented' => 'Array')
end

# A relation resolves through #klass, so #first must not be reached.
it 'does not call #first when the object resolves without it' do
model = Class.new
relation = Class.new do
def initialize(klass) = (@klass = klass)
attr_reader :klass

def first = raise('#first should not have been called')
end
present_with(subject, model, relation.new(model), entity)

expect { get '/' }.not_to raise_error
expect(last_response.status).to eq(200)
end
end
end

describe '.namespace' do
Expand Down
Loading