Skip to content
Draft
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 @@ -3,6 +3,7 @@
#### Features

* [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx).
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Instantiate validators at compile time - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
1 change: 0 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ group :test do
gem 'rspec', '~> 3.13'
gem 'simplecov', '~> 0.21', require: false
gem 'simplecov-lcov', '~> 0.8', require: false
gem 'test-prof', require: false
end

platforms :jruby do
Expand Down
86 changes: 86 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,92 @@
Upgrading Grape
===============

### Upgrading to >= 3.2

#### Validators Instantiated at Definition Time

Previously, validators were instantiated at request time but they are now instantiated at definition time. This reduces object allocations since instances are reused across requests.

#### `Grape::Util::Translation` Module

I18n translation logic (translate with fallback locale) has been extracted into `Grape::Util::Translation`, included by both `Grape::Exceptions::Base` and `Grape::Validations::Validators::Base`. The `FALLBACK_LOCALE` constant has moved from `Grape::Exceptions::Base` to `Grape::Util::Translation`:

#### `Grape::Exceptions::Base#translate_message` Supports Hash Messages

`translate_message` now accepts a Hash with a `:key` and interpolation parameters for deferred I18n translation:

```ruby
# Symbol (unchanged)
translate_message(:presence)

# Hash (new) — key + interpolation params, translated at error-raise time
translate_message({ key: :length, min: 2, max: 5 })
```

This is used by validators that need locale-sensitive messages with interpolation (e.g. `LengthValidator`, `SameAsValidator`).

#### `Grape::Exceptions::Validation` Changes

**`params` and `message_key` are now read-only.** `attr_accessor` has been changed to `attr_reader`. If you were assigning to these after initialization, set them via the constructor keyword arguments instead.

**`params` is now always coerced to an array.** You can now pass a single string instead of wrapping it in an array:

```ruby
# Before
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')

# After (both work, single string is now accepted)
Grape::Exceptions::Validation.new(params: 'my_param', message: 'is invalid')
Grape::Exceptions::Validation.new(params: ['my_param'], message: 'is invalid')
```

#### `Validators::Base` Method Visibility Changes

The following methods on `Grape::Validations::Validators::Base` are now **private**: `validate!`, `message`, `options_key?`. If your custom validator subclass calls these via `super` from a private method, no change is needed. If you were calling them from outside the class, you'll need to adjust.

New private helpers have been added:
- `hash_like?(obj)` — returns `obj.respond_to?(:key?)`
- `option_value` — returns `@option[:value]` if present, otherwise `@option`
- `scrub(value)` — scrubs invalid-encoding strings
- `translate_message(key, **)` — translates a message key using the `grape.errors.messages` I18n scope with fallback locale support

#### `Validators::Base#message` Now Accepts a Block

`message` now accepts an optional block for lazy default message generation. When no custom `:message` option is set and no `default_key` is provided, the block is called:

```ruby
# Before
def message(default_key = nil)
options_key?(:message) ? @option[:message] : default_key
end

# After
def message(default_key = nil)
key = options_key?(:message) ? @option[:message] : default_key
return key if key

yield if block_given?
end
```

If your custom validator overrides `message` or passes a `default_key`, the behavior is unchanged. If you relied on `message` returning `nil` when no custom message and no default key were set, it now yields to the block instead.

#### `ContractScopeValidator` No Longer Inherits from `Base`

`ContractScopeValidator` is now a standalone class that no longer inherits from `Grape::Validations::Validators::Base`. Its constructor takes a single `schema:` keyword argument instead of the standard 5-argument validator signature:

```ruby
# Before
ContractScopeValidator.new(attrs, options, required, scope, opts)

# After
ContractScopeValidator.new(schema: contract)
```

#### Validator Constructor Caching

All built-in validators now eagerly compute and cache values in their constructors (exception messages, option values, lambdas for proc-based defaults/values). This is transparent to API consumers but relevant if you subclass built-in validators and override `initialize` — ensure you call `super` so caching is properly set up.

### Upgrading to >= 3.1

#### Explicit kwargs for `namespace` and `route_param`
Expand Down
19 changes: 6 additions & 13 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def run
status 204
else
run_filters before_validations, :before_validation
run_validators validations, request
run_validators request: request
run_filters after_validations, :after_validation
response_object = execute
end
Expand Down Expand Up @@ -205,10 +205,13 @@ def execute
end
end

def run_validators(validators, request)
def run_validators(request:)
validators = inheritable_setting.route[:saved_validations]
return if validators.blank?

validation_errors = []

ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do
ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request:) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation => e
Expand Down Expand Up @@ -237,16 +240,6 @@ def run_filters(filters, type = :other)
end
end

def validations
saved_validations = inheritable_setting.route[:saved_validations]
return if saved_validations.nil?
return enum_for(:validations) unless block_given?

saved_validations.each do |saved_validation|
yield Grape::Validations::ValidatorFactory.create_validator(saved_validation)
end
end

def options?
options[:options_route_enabled] &&
env[Rack::REQUEST_METHOD] == Rack::OPTIONS
Expand Down
49 changes: 18 additions & 31 deletions lib/grape/exceptions/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
module Grape
module Exceptions
class Base < StandardError
BASE_MESSAGES_KEY = 'grape.errors.messages'
BASE_ATTRIBUTES_KEY = 'grape.errors.attributes'
FALLBACK_LOCALE = :en
include Grape::Util::Translation

attr_reader :status, :headers

Expand All @@ -25,50 +23,39 @@ def [](index)
# TODO: translate attribute first
# if BASE_ATTRIBUTES_KEY.key respond to a string message, then short_message is returned
# if BASE_ATTRIBUTES_KEY.key respond to a Hash, means it may have problem , summary and resolution
def compose_message(key, **attributes)
short_message = translate_message(key, attributes)
def compose_message(key, **)
short_message = translate_message(key, **)
return short_message unless short_message.is_a?(Hash)

each_steps(key, attributes).with_object(+'') do |detail_array, message|
each_steps(key, **).with_object(+'') do |detail_array, message|
message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank?
end
end

def each_steps(key, attributes)
return enum_for(:each_steps, key, attributes) unless block_given?
def each_steps(key, **)
return enum_for(:each_steps, key, **) unless block_given?

yield 'Problem', translate_message(:"#{key}.problem", attributes)
yield 'Summary', translate_message(:"#{key}.summary", attributes)
yield 'Resolution', translate_message(:"#{key}.resolution", attributes)
yield 'Problem', translate_message(:"#{key}.problem", **)
yield 'Summary', translate_message(:"#{key}.summary", **)
yield 'Resolution', translate_message(:"#{key}.resolution", **)
end

def translate_attributes(keys, options = {})
def translate_attributes(keys, **)
keys.map do |key|
translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s))
translate(key, scope: 'grape.errors.attributes', default: key.to_s, **)
end.join(', ')
end

def translate_message(key, options = {})
case key
def translate_message(translation_key, **)
case translation_key
when Symbol
translate("#{BASE_MESSAGES_KEY}.#{key}", options.merge(default: ''))
translate(translation_key, scope: 'grape.errors.messages', **)
when Hash
translate(translation_key[:key], scope: 'grape.errors.messages', **translation_key.except(:key))
when Proc
key.call
translation_key.call
else
key
end
end

def translate(key, options)
message = ::I18n.translate(key, **options)
message.presence || fallback_message(key, options)
end

def fallback_message(key, options)
if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
key
else
::I18n.translate(key, locale: FALLBACK_LOCALE, **options)
translation_key
end
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/grape/exceptions/validation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
module Grape
module Exceptions
class Validation < Base
attr_accessor :params, :message_key
attr_reader :params, :message_key

def initialize(params:, message: nil, status: nil, headers: nil)
@params = params
@params = params.is_a?(Array) ? params : [params]
if message
@message_key = message if message.is_a?(Symbol)
message = translate_message(message)
Expand Down
22 changes: 22 additions & 0 deletions lib/grape/util/translation.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module Grape
module Util
module Translation
FALLBACK_LOCALE = :en

private

def translate(key, default: '', scope: nil, **)
message = ::I18n.translate(key, default:, scope:, **)
return message if message.present?

if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE)
scope ? "#{scope}.#{key}" : key
else
::I18n.translate(key, default:, scope:, locale: FALLBACK_LOCALE, **)
end
end
end
end
end
8 changes: 1 addition & 7 deletions lib/grape/validations/contract_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,7 @@ def initialize(api, contract = nil, &block)
end

api.inheritable_setting.namespace_stackable[:contract_key_map] = key_map

validator_options = {
validator_class: Grape::Validations.require_validator(:contract_scope),
opts: { schema: contract, fail_fast: false }
}

api.inheritable_setting.namespace_stackable[:validations] = validator_options
api.inheritable_setting.namespace_stackable[:validations] = Validators::ContractScopeValidator.new(schema: contract)
end
end
end
Expand Down
25 changes: 11 additions & 14 deletions lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,7 @@ def validates(attrs, validations)
# Before we run the rest of the validators, let's handle
# whatever coercion so that we are working with correctly
# type casted values
coerce_type validations, attrs, required, opts
coerce_type validations.extract!(:coerce, :coerce_with, :coerce_message), attrs, required, opts

validations.each do |type, options|
# Don't try to look up validators for documentation params that don't have one.
Expand Down Expand Up @@ -430,17 +430,14 @@ def check_coerce_with(validations)
def coerce_type(validations, attrs, required, opts)
check_coerce_with(validations)

return unless validations.key?(:coerce)
return unless validations[:coerce]

coerce_options = {
type: validations[:coerce],
method: validations[:coerce_with],
message: validations[:coerce_message]
}
validate('coerce', coerce_options, attrs, required, opts)
validations.delete(:coerce_with)
validations.delete(:coerce)
validations.delete(:coerce_message)
end

def guess_coerce_type(coerce_type, *values_list)
Expand All @@ -464,15 +461,15 @@ def check_incompatible_option_values(default, values, except_values)
end

def validate(type, options, attrs, required, opts)
validator_options = {
attributes: attrs,
options: options,
required: required,
params_scope: self,
opts: opts,
validator_class: Validations.require_validator(type)
}
@api.inheritable_setting.namespace_stackable[:validations] = validator_options
validator_class = Validations.require_validator(type)
validator_instance = validator_class.new(
attrs,
options,
required,
self,
opts
)
@api.inheritable_setting.namespace_stackable[:validations] = validator_instance
end

def validate_value_coercion(coerce_type, *values_list)
Expand Down
15 changes: 0 additions & 15 deletions lib/grape/validations/validator_factory.rb

This file was deleted.

7 changes: 6 additions & 1 deletion lib/grape/validations/validators/all_or_none_of_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,16 @@ module Grape
module Validations
module Validators
class AllOrNoneOfValidator < MultipleParamsBase
def initialize(attrs, options, required, scope, opts)
super
@exception_message = message(:all_or_none)
end

def validate_params!(params)
keys = keys_in_common(params)
return if keys.empty? || keys.length == all_keys.length

raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:all_or_none))
raise Grape::Exceptions::Validation.new(params: all_keys, message: @exception_message)
end
end
end
Expand Down
15 changes: 10 additions & 5 deletions lib/grape/validations/validators/allow_blank_validator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,20 @@ module Grape
module Validations
module Validators
class AllowBlankValidator < Base
def validate_param!(attr_name, params)
return if (options_key?(:value) ? @option[:value] : @option) || !params.is_a?(Hash)
def initialize(attrs, options, required, scope, opts)
super

@value = option_value
@exception_message = message(:blank)
end

value = params[attr_name]
value = value.scrub if value.respond_to?(:valid_encoding?) && !value.valid_encoding?
def validate_param!(attr_name, params)
return if @value || !hash_like?(params)

value = scrub(params[attr_name])
return if value == false || value.present?

raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:blank))
raise Grape::Exceptions::Validation.new(params: @scope.full_name(attr_name), message: @exception_message)
end
end
end
Expand Down
Loading
Loading