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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
* [#2663](https://github.com/ruby-grape/grape/pull/2663): Refactor `ParamsScope` and `Parameters` DSL to use named kwargs - [@ericproulx](https://github.com/ericproulx).
* [#2664](https://github.com/ruby-grape/grape/pull/2664): Drop `test-prof` dependency - [@ericproulx](https://github.com/ericproulx).
* [#2665](https://github.com/ruby-grape/grape/pull/2665): Pass `attrs` directly to `AttributesIterator` instead of `validator` - [@ericproulx](https://github.com/ericproulx).
* [#2657](https://github.com/ruby-grape/grape/pull/2657): Instantiate validators at definition time - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
68 changes: 54 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1774,9 +1774,9 @@ end
```ruby
class AlphaNumeric < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
unless params[attr_name] =~ /\A[[:alnum:]]+\z/
raise Grape::Exceptions::Validation.new params: [@scope.full_name(attr_name)], message: 'must consist of alpha-numeric characters'
end
return if params[attr_name].match?(/\A[[:alnum:]]+\z/)

validation_error!(attr_name, 'must consist of alpha-numeric characters')
end
end
```
Expand All @@ -1792,9 +1792,9 @@ You can also create custom classes that take parameters.
```ruby
class Length < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
unless params[attr_name].length <= @option
raise Grape::Exceptions::Validation.new params: [@scope.full_name(attr_name)], message: "must be at the most #{@option} characters long"
end
return if params[attr_name].length <= @options

validation_error!(attr_name, "must be at the most #{@options} characters long")
end
end
```
Expand All @@ -1816,10 +1816,10 @@ class Admin < Grape::Validations::Validators::Base
# @attrs being [:admin_field] and once with @attrs being [:admin_false_field]
return unless request.params.key?(@attrs.first)
# check if admin flag is set to true
return unless @option
return unless @options
# check if user is admin or not
# as an example get a token from request and check if it's admin or not
raise Grape::Exceptions::Validation.new params: @attrs, message: 'Can not set admin-only field.' unless request.headers['X-Access-Token'] == 'admin'
validation_error!(@attrs, 'Can not set admin-only field.') unless request.headers['X-Access-Token'] == 'admin'
end
end
```
Expand All @@ -1834,7 +1834,50 @@ params do
end
```

Every validation will have its own instance of the validator, which means that the validator can have a state.
Each validator is instantiated once at route definition time and frozen. Any setup (option parsing, message building) should happen in `initialize`, not in `validate_param!` or `validate`.

#### Available helpers

The following protected/private helpers are available in any `Grape::Validations::Validators::Base` subclass:

| Helper | Description |
|---|---|
| `default_message_key(key)` | Class-level macro. Declares the default I18n key for `validation_error!`. A per-option `:message` override still takes precedence. |
| `validation_error!(attr_name_or_params, message = @exception_message)` | Raises `Grape::Exceptions::Validation`. Accepts a single attribute name or a pre-computed array of full param names. |
| `@options` | The validator option value, deep-frozen at initialization. |
| `@attrs` | Frozen array of attribute names this validator applies to. |
| `@scope` | The `ParamsScope` — use `@scope.full_name(attr_name)` for the fully-qualified param name. |
| `option_value` | Returns `@options[:value]` if present, otherwise `@options`. |
| `options_key?(key)` | Returns true if `@options` is a hash with a non-nil `key`. |
| `hash_like?(obj)` | Returns true if `obj` responds to `key?`. |
| `scrub(value)` | Returns `value` with invalid byte sequences scrubbed. |
| `translate(key, **opts)` | I18n lookup with `:en` fallback and `grape.errors.messages` scope. Called at request time to respect per-request locale. |

Use `default_message_key` for a fixed I18n key. The message is resolved once at route definition time via `message`, so a per-option `:message` override still wins:

```ruby
class SpecialValidator < Grape::Validations::Validators::Base
default_message_key :special

def validate_param!(attr_name, params)
return if valid?(params[attr_name])

validation_error!(attr_name)
end
end
```

For interpolated messages that must respect per-request locale, call `translate` directly inside `validate_param!`:

```ruby
class SpecialValidator < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
return if valid?(params[attr_name])

validation_error!(attr_name, translate(:special, min: 2, max: 10))
end
end
```

### Validation Errors

Expand Down Expand Up @@ -1906,17 +1949,14 @@ translate(:special, min: 2, max: 10)
format I18n.t(:special, scope: 'grape.errors.messages'), min: 2, max: 10
```

Example custom validator:
Example custom validator using an interpolated i18n message:

```ruby
class SpecialValidator < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
return if valid?(params[attr_name])

raise Grape::Exceptions::Validation.new(
params: [@scope.full_name(attr_name)],
message: translate(:special, min: 2, max: 10)
)
validation_error!(attr_name, translate(:special, min: 2, max: 10))
end
end
```
Expand Down
39 changes: 39 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,45 @@ Upgrading Grape

### Upgrading to >= 3.2

#### Custom validators: use `default_message_key` and `validation_error!`

Validators are now instantiated once at definition time and frozen. Any setup should happen in `initialize`, not in `validate_param!`.
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.

So will users have to rewrite custom validators? Explain how if so with a simple example.


If your custom validator did work in `validate_param!` that only depends on the validator's options (not the param value), move it to `initialize`. A common case is compiling a value derived from options — for example, building a `Regexp`. Previously this may have been cached back into `@options`, which now raises `FrozenError` since `@options` and its nested values are deep-frozen by the base class:

**Before:**
```ruby
class MyValidator < Grape::Validations::Validators::Base
def validate_param!(attr_name, params)
# raises FrozenError: @options is frozen, cannot store compiled pattern back into it
@options[:compiled] ||= Regexp.new(@options[:pattern])
validation_error!(attr_name) unless params[attr_name].match?(@options[:compiled])
end
end
```

**After:**
```ruby
class MyValidator < Grape::Validations::Validators::Base
def initialize(attrs, options, required, scope, opts)
super
@pattern = Regexp.new(@options[:pattern]).freeze
end

def validate_param!(attr_name, params)
validation_error!(attr_name) unless params[attr_name].match?(@pattern)
end
end
```

Any Array or Hash derived from options and stored in an ivar should be frozen, since the validator instance is shared across requests. `@options` itself (and any nested Hash/Array/String values within it) is deep-frozen by the base class, so mutations like `@options[:values] << 'extra'` will also raise a `FrozenError`.

#### Custom validators: rename `@option` to `@options`

The instance variable holding the validator's option value has been renamed from `@option` to `@options`. `@option` remains as an alias for backwards compatibility but will be removed in the next major release. Update any custom validators to use `@options` instead.

Several new helpers are available — see [Available helpers](README.md#available-helpers) in the README for full documentation and examples.

#### `with` now uses keyword arguments

The `with` DSL method now uses `**opts` instead of a positional hash. Calls using bare keyword syntax are unaffected:
Expand Down
19 changes: 5 additions & 14 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,11 +205,12 @@ def execute
end
end

def run_validators(validators, request)
def run_validators(request:)
validators = inheritable_setting.route[:saved_validations]
validation_errors = []

Grape::Validations::ParamScopeTracker.track do
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 All @@ -222,7 +223,7 @@ def run_validators(validators, request)
end
end

validation_errors.any? && raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header))
raise(Grape::Exceptions::ValidationErrors.new(errors: validation_errors, headers: header)) if validation_errors.any?
end

def run_filters(filters, type = :other)
Expand All @@ -239,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
35 changes: 35 additions & 0 deletions lib/grape/util/deep_freeze.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# frozen_string_literal: true

module Grape
module Util
module DeepFreeze
module_function

# Recursively freezes Hash (keys and values), Array (elements), and String
# objects. All other types are returned as-is.
#
# Intentionally left unfrozen:
# - Procs / lambdas — may be deferred DB-backed callables
# - Coercers (e.g. ArrayCoercer) — use lazy ivar memoization at request time
# - Classes / Modules — shared constants that must remain open
# - ParamsScope — self-freezes at the end of its own initialize
def deep_freeze(obj)
case obj
when Hash
obj.each do |k, v|
deep_freeze(k)
deep_freeze(v)
end
obj.freeze
when Array
obj.each { |v| deep_freeze(v) }
obj.freeze
when String
obj.freeze
else
obj
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
Loading
Loading