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 .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ AllCops:
Exclude:
- vendor/**/*
- bin/**/*
- benchmark/**/*

plugins:
- rubocop-performance
Expand Down
14 changes: 5 additions & 9 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config`
# on 2026-01-31 18:13:50 UTC using RuboCop version 1.84.0.
# on 2026-04-08 20:39:39 UTC using RuboCop version 1.86.0.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
Expand Down Expand Up @@ -59,7 +59,7 @@ RSpec/IndexedLet:
- 'spec/grape/presenters/presenter_spec.rb'
- 'spec/shared/versioning_examples.rb'

# Offense count: 40
# Offense count: 41
# Configuration parameters: AssignmentOnly.
RSpec/InstanceVariable:
Exclude:
Expand All @@ -84,19 +84,16 @@ RSpec/MissingExampleGroupArgument:
Exclude:
- 'spec/grape/middleware/exception_spec.rb'

# Offense count: 12
# Offense count: 6
RSpec/RepeatedDescription:
Exclude:
- 'spec/grape/api_spec.rb'
- 'spec/grape/endpoint_spec.rb'
- 'spec/grape/validations/validators/allow_blank_validator_spec.rb'
- 'spec/grape/validations/validators/values_validator_spec.rb'

# Offense count: 6
# Offense count: 2
RSpec/RepeatedExample:
Exclude:
- 'spec/grape/middleware/versioner/accept_version_header_spec.rb'
- 'spec/grape/validations/validators/allow_blank_validator_spec.rb'

# Offense count: 8
RSpec/RepeatedExampleGroupDescription:
Expand Down Expand Up @@ -144,15 +141,14 @@ Style/CombinableLoops:
Exclude:
- 'spec/grape/endpoint_spec.rb'

# Offense count: 10
# Offense count: 9
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter:
Exclude:
- 'lib/grape/dsl/parameters.rb'
- 'lib/grape/endpoint.rb'
- 'lib/grape/serve_stream/sendfile_response.rb'
- 'lib/grape/validations/params_scope.rb'
- 'lib/grape/validations/types/array_coercer.rb'
- 'lib/grape/validations/types/custom_type_collection_coercer.rb'
- 'lib/grape/validations/types/dry_type_coercer.rb'
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@

#### Features

* [#2679](https://github.com/ruby-grape/grape/pull/2679): Extract entity dsl and refactor :with to keyword argument - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes

* [#2678](https://github.com/ruby-grape/grape/pull/2678): Update rubocop to 1.86.0 and autocorrect offenses - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

### 3.2.0 (2026-04-08)
Expand Down
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ group :development, :test do
gem 'builder', require: false
gem 'bundler'
gem 'rake'
gem 'rubocop', '1.84.0', require: false
gem 'rubocop', '1.86.0', require: false
gem 'rubocop-performance', '1.26.1', require: false
gem 'rubocop-rspec', '3.9.0', require: false
end
Expand Down
85 changes: 85 additions & 0 deletions lib/grape/dsl/entity.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# frozen_string_literal: true

module Grape
module DSL
module Entity
# Allows you to make use of Grape Entities by setting
# the response body to the serializable hash of the
# entity provided in the `:with` option. This has the
# added benefit of automatically passing along environment
# and version information to the serialization, making it
# very easy to do conditional exposures. See Entity docs
# for more info.
#
# @param args [Array] either `(object)` or `(key, object)` where key is a Symbol
# used to nest the representation under that key in the response body.
# @param root [Symbol, String, nil] wraps the representation under this root key.
# @param with [Class, nil] the entity class to use for representation.
# If omitted, the entity class is inferred from the object via {#entity_class_for_obj}.
# @param options [Hash] additional options forwarded to the entity's `represent` call.
#
# @example
#
# get '/users/:id' do
# present User.find(params[:id]),
# with: API::Entities::User,
# admin: current_user.admin?
# end
def present(*args, root: nil, with: nil, **options)
key, object = args.count == 2 && args.first.is_a?(Symbol) ? args : [nil, args.first]
entity_class = with || entity_class_for_obj(object)
representation = entity_class ? entity_representation_for(entity_class, object, options) : object
representation = { root => representation } if root

if key
representation = (body || {}).merge(key => representation)
elsif entity_class.present? && body
raise ArgumentError, "Representation of type #{representation.class} cannot be merged." unless representation.respond_to?(:merge)

representation = body.merge(representation)
end

body representation
end

# Attempt to locate the Entity class for a given object, if not given
# explicitly. This is done by looking for the presence of Klass::Entity,
# where Klass is the class of the `object` parameter, or one of its
# ancestors.
# @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)
object_class =
if object.respond_to?(:klass)
object.klass
elsif object.respond_to?(:first)
object.first.class
else
object.class
end

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

return unless object_class.const_defined?(:Entity)

entity = object_class.const_get(:Entity)
entity if entity.respond_to?(:represent)
end

private

# @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.
# @return the representation of the given object as done through the given entity_class.
def entity_representation_for(entity_class, object, options)
embeds = env.key?(Grape::Env::API_VERSION) ? { env:, version: env[Grape::Env::API_VERSION] } : { env: }
entity_class.represent(object, **embeds, **options)
end
end
end
end
82 changes: 1 addition & 81 deletions lib/grape/dsl/inside_route.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ module Grape
module DSL
module InsideRoute
include Declared
include Entity

# Backward compatibility: alias exception class to previous location
MethodNotYetAvailable = Declared::MethodNotYetAvailable
Expand Down Expand Up @@ -184,50 +185,6 @@ def stream(value = nil)
end
end

# Allows you to make use of Grape Entities by setting
# the response body to the serializable hash of the
# entity provided in the `:with` option. This has the
# added benefit of automatically passing along environment
# and version information to the serialization, making it
# very easy to do conditional exposures. See Entity docs
# for more info.
#
# @example
#
# get '/users/:id' do
# present User.find(params[:id]),
# with: API::Entities::User,
# admin: current_user.admin?
# end
def present(*args, **options)
key, object = if args.count == 2 && args.first.is_a?(Symbol)
args
else
[nil, args.first]
end
entity_class = entity_class_for_obj(object, options)

root = options.delete(:root)

representation = if entity_class
entity_representation_for(entity_class, object, options)
else
object
end

representation = { root => representation } if root

if key
representation = (body || {}).merge(key => representation)
elsif entity_class.present? && body
raise ArgumentError, "Representation of type #{representation.class} cannot be merged." unless representation.respond_to?(:merge)

representation = body.merge(representation)
end

body representation
end

# Returns route information for the current request.
#
# @example
Expand All @@ -240,43 +197,6 @@ def route
env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info]
end

# Attempt to locate the Entity class for a given object, if not given
# explicitly. This is done by looking for the presence of Klass::Entity,
# where Klass is the class of the `object` parameter, or one of its
# ancestors.
# @param object [Object] the object to locate the Entity class for
# @param options [Hash]
# @option options :with [Class] the explicit entity class to use
# @return [Class] the located Entity class, or nil if none is found
def entity_class_for_obj(object, options)
entity_class = options.delete(:with)
return entity_class if entity_class

# entity class not explicitly defined, auto-detect from relation#klass or first object in the collection
object_class = if object.respond_to?(:klass)
object.klass
else
object.respond_to?(:first) ? object.first.class : object.class
end

representations = inheritable_setting.namespace_stackable_with_hash(:representations)
if representations
potential = object_class.ancestors.detect { |potential| representations.key?(potential) }
entity_class = representations[potential] if potential
end

entity_class = object_class.const_get(:Entity) if !entity_class && object_class.const_defined?(:Entity) && object_class.const_get(:Entity).respond_to?(:represent)
entity_class
end

# @return the representation of the given object as done through
# the given entity_class.
def entity_representation_for(entity_class, object, options)
embeds = { env: }
embeds[:version] = env[Grape::Env::API_VERSION] if env.key?(Grape::Env::API_VERSION)
entity_class.represent(object, **embeds, **options)
end

def http_version
env.fetch('HTTP_VERSION') { env[Rack::SERVER_PROTOCOL] }
end
Expand Down
40 changes: 14 additions & 26 deletions lib/grape/dsl/request_response.rb
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,8 @@ def default_error_formatter(new_formatter_name = nil)
inheritable_setting.namespace_inheritable[:default_error_formatter] = new_formatter
end

def error_formatter(format, options)
formatter = if options.is_a?(Hash) && options.key?(:with)
options[:with]
else
options
end

def error_formatter(format, options = nil, with: nil)
formatter = with || options
inheritable_setting.namespace_stackable[:error_formatters] = { format.to_sym => formatter }
end

Expand Down Expand Up @@ -93,16 +88,8 @@ def default_error_status(new_status = nil)
# @option options [Boolean] :rescue_subclasses Also rescue subclasses of exception classes
# @param [Proc] handler Execution proc to handle the given exception as an
# alternative to passing a block.
def rescue_from(*args, **options, &block)
if args.last.is_a?(Proc)
handler = args.pop
elsif block
handler = block
end

raise ArgumentError, 'both :with option and block cannot be passed' if block && options.key?(:with)

handler ||= extract_with(options)
def rescue_from(*args, with: nil, **options, &block)
handler = extract_handler(args, with:, block:)

if args.include?(:all)
inheritable_setting.namespace_inheritable[:rescue_all] = true
Expand Down Expand Up @@ -146,22 +133,23 @@ def rescue_from(*args, **options, &block)
#
# @param model_class [Class] The model class that will be represented.
# @option options [Class] :with The entity class that will represent the model.
def represent(model_class, options)
raise Grape::Exceptions::InvalidWithOptionForRepresent.new unless options[:with].is_a?(Class)
def represent(model_class, with:)
raise Grape::Exceptions::InvalidWithOptionForRepresent.new unless with.is_a?(Class)

inheritable_setting.namespace_stackable[:representations] = { model_class => options[:with] }
inheritable_setting.namespace_stackable[:representations] = { model_class => with }
end

private

def extract_with(options)
return unless options.key?(:with)
def extract_handler(args, with:, block:)
raise ArgumentError, 'both :with option and block cannot be passed' if block && with

with_option = options.delete(:with)
return with_option if with_option.instance_of?(Proc)
return with_option.to_sym if with_option.instance_of?(Symbol) || with_option.instance_of?(String)
return args.pop if args.last.is_a?(Proc)
return block if block
return with if with.instance_of?(Proc) || with.instance_of?(Symbol)
return with.to_sym if with.instance_of?(String)

raise ArgumentError, "with: #{with_option.class}, expected Symbol, String or Proc"
raise ArgumentError, "with: #{with.class}, expected Symbol, String or Proc" if with
end
end
end
Expand Down
25 changes: 12 additions & 13 deletions lib/grape/error_formatter/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,33 @@ def call(message, backtrace, options = {}, env = nil, original_exception = nil)
end

def present(message, env)
present_options = {}
presented_message = message
if presented_message.is_a?(Hash)
presented_message = presented_message.dup
present_options[:with] = presented_message.delete(:with)
# error! accepts a message hash with an optional :with key specifying the entity presenter.
# Extract it here so the presenter can be resolved and the key is not serialized in the response.
# See spec/integration/grape_entity/entity_spec.rb for examples.
with = nil
if message.is_a?(Hash) && message.key?(:with)
message = message.dup
with = message.delete(:with)
end

presenter = env[Grape::Env::API_ENDPOINT].entity_class_for_obj(presented_message, present_options)
presenter = with || env[Grape::Env::API_ENDPOINT].entity_class_for_obj(message)

unless presenter || env[Grape::Env::GRAPE_ROUTING_ARGS].nil?
# env['api.endpoint'].route does not work when the error occurs within a middleware
# the Endpoint does not have a valid env at this moment
http_codes = env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info].http_codes || []

found_code = http_codes.find do |http_code|
(http_code[0].to_i == env[Grape::Env::API_ENDPOINT].status) && http_code[2].respond_to?(:represent)
end if env[Grape::Env::API_ENDPOINT].request

presenter = found_code[2] if found_code
end

if presenter
embeds = { env: }
embeds[:version] = env[Grape::Env::API_VERSION] if env.key?(Grape::Env::API_VERSION)
presented_message = presenter.represent(presented_message, embeds).serializable_hash
end
return message unless presenter

presented_message
embeds = { env: }
embeds[:version] = env[Grape::Env::API_VERSION] if env.key?(Grape::Env::API_VERSION)
presenter.represent(message, embeds).serializable_hash
end

def wrap_message(message)
Expand Down
Loading
Loading