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 @@ -5,6 +5,7 @@
* [#3019](https://github.com/ruby-grape/grape/pull/3019): Add `API.eager_load!` to compile an API and load its I18n translations at boot - [@ericproulx](https://github.com/ericproulx).
* [#3022](https://github.com/ruby-grape/grape/pull/3022): Compile the APIs mounted in a Rails application's routes at boot when it eager loads - [@ericproulx](https://github.com/ericproulx).
* [#3052](https://github.com/ruby-grape/grape/pull/3052): Add `Grape::ErrorFormatter::ProblemJson` to answer errors as RFC 9457 problem details - [@ericproulx](https://github.com/ericproulx).
* [#3054](https://github.com/ruby-grape/grape/pull/3054): Add `Grape.config.allow_undeclared_params`, answering 400 to undeclared params when `false`, the Grape 5.0 default - [@ericproulx](https://github.com/ericproulx).
* [#3056](https://github.com/ruby-grape/grape/pull/3056): Drop support for Rails 7.2 and require ActiveSupport 8.0 - [@ericproulx](https://github.com/ericproulx).
* [#3062](https://github.com/ruby-grape/grape/pull/3062): Use the reusable Danger workflow with a separate Gemfile - [@dblock](https://github.com/dblock).
* [#3063](https://github.com/ruby-grape/grape/pull/3063): Drop support for Rack 2.2 and 3.0 and require Rack 3.1 - [@ericproulx](https://github.com/ericproulx).
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -595,6 +595,7 @@ Currently the configurable settings are:
* `warn_on_helper_overrides`: Warns when a helper method masks a `Grape::Endpoint` instance method, defaults to `false`.
* `raise_rendering_errors`: Lets an error response that cannot be rendered propagate out of the middleware stack instead of being answered with a failsafe `500`, defaults to `false`. See [When the error response itself cannot be rendered](#when-the-error-response-itself-cannot-be-rendered).
* `raise_rescue_handler_errors`: Lets an exception raised inside a `rescue_from` block propagate out of the middleware stack instead of being redispatched, defaults to `false`. See [Re-raising from inside a `rescue_from` block](#re-raising-from-inside-a-rescue_from-block).
* `allow_undeclared_params`: Hands an endpoint the params a request carries that it did not declare, defaults to `true` and to `false` from Grape 5.0. Set to `false`, such a request is answered `400`. See [Rejecting Undeclared Parameters](#rejecting-undeclared-parameters).

To change a setting value make sure that at some point during load time the following code runs

Expand Down Expand Up @@ -714,6 +715,8 @@ Grape allows you to access only the parameters that have been declared by your `
* Include any optional params that are declared but not passed.
* Perform any parameter renaming on the resulting hash.

To answer `400` to a request that passes params the endpoint did not declare, see [Rejecting Undeclared Parameters](#rejecting-undeclared-parameters).

Consider the following API endpoint:

````ruby
Expand Down Expand Up @@ -2005,6 +2008,50 @@ params do
end
```

### Rejecting Undeclared Parameters

By default, an endpoint gets every param a request carries, declared or not, and [`declared(params)`](#declared) is how it keeps to the ones it declared. With `allow_undeclared_params` set to `false`, a request that carries a param its endpoint did not declare is answered with a `400` instead, the param reported along with any other [validation error](#validation-errors). Grape 5.0 makes `false` the default.

```ruby
Grape.configure do |config|
config.allow_undeclared_params = false
end
```

```ruby
format :json

params do
requires :name, type: String
optional :address, type: Hash do
requires :street, type: String
end
end
post '/users' do
params
end
```

```bash
curl -X POST -H 'Content-Type: application/json' localhost:9292/users -d '{"name":"Bob","admin":true,"address":{"street":"Main","zip":"1"}}'
# {"error":"admin is not allowed, address[zip] is not allowed"}
```

A param counts as declared when it is:

* declared with `requires` or `optional`, in the endpoint's `params` block or in that of a namespace around it, `given` blocks included. A param renamed with `as:` is sent under the name it is declared with.
* a key of the endpoint's [contract](#using-dry-validation-or-dry-schema).
* a segment of the endpoint's path, such as `:id`.
* `format`, which Grape reads to [choose the response format](#api-formats).

The keys of a `Hash` or `JSON` param declared with a block, and those of each element of an `Array` param, are checked against the block, and an undeclared one is named by its path, such as `items[1][price]`. A param declared without a block, such as a `File`, a `Hash` or `JSON` param without one, or a [`oneof:`](#multiple-hash-schemas-with-oneof) param, takes whatever it is sent.

* An endpoint that declares no params accepts none. A param that only a `before` filter or a helper reads, such as an access token, has to be declared as well. Declaring it in a namespace's `params` block, or in a [reusable params](#helpers) block, declares it for every endpoint that block applies to.
* The params are parsed to be checked, so an endpoint that never reads `params` answers `400` to a request whose query string or body cannot be parsed.
* The version a client sends with [`using: :param`](#param) is a param like any other on an endpoint that its API does not version.

The message is the `undeclared` key of [en.yml](lib/grape/locale/en.yml).

### I18n

Grape supports I18n for parameter-related error messages, but will fallback to English if translations for the default locale have not been provided. See [en.yml](lib/grape/locale/en.yml) for message keys.
Expand Down
12 changes: 12 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ When `config.eager_load` is on, as it is in production, Grape's Railtie now comp

Compiling builds each endpoint's middleware stack, so a middleware's `initialize` now runs at boot, and before the workers fork under a server that preloads the application, such as Puma with `preload_app!`. A middleware that opens a connection or starts a thread in `initialize` should do it on its first request instead. An API that fails to compile now fails the boot rather than its first request.

#### Undeclared params can be rejected, and will be by default from Grape 5.0

Set to `false`, `Grape.config.allow_undeclared_params` answers `400` to a request carrying a param its endpoint did not declare ([#3054](https://github.com/ruby-grape/grape/pull/3054)). It defaults to `true`, so nothing changes in 4.2.0, but Grape 5.0 makes `false` the default.

To find out ahead of 5.0 what it would reject, set it to `false` in your test suite and declare what your endpoints read without declaring, such as an access token read by a `before` filter:

```ruby
Grape.config.allow_undeclared_params = false
```

To keep accepting undeclared params from 5.0, set it to `true`. See [Rejecting Undeclared Parameters](README.md#rejecting-undeclared-parameters).

#### A request every route declines answers 404, however many routes declined

A route declines a request by answering with `X-Cascade: pass`, as a version declared with `cascade: true` does for a version it does not serve. As the [README](README.md#header) documents, the router then answers its own `404` if no other route takes the request. It only did so when one route had declined: with two or more routes on the path, the last one's response came back instead, and so did that of an ANY route (since 4.0.0). So a request for an unknown version answered `404` from an API with one version and `406` from an API with several.
Expand Down
4 changes: 4 additions & 0 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ module Grape
# Let an exception raised inside a `rescue_from` block propagate out of the
# middleware stack instead of being redispatched.
setting :raise_rescue_handler_errors, default: false
# Hand an endpoint the params a request carries that it did not declare.
# When false, such a request is answered 400. Grape 5.0 makes false the
# default.
setting :allow_undeclared_params, default: true

# The HTTP QUERY method (RFC 10008): a safe, idempotent request whose content
# carries the query. Rack has no constant for it yet, hence the literal.
Expand Down
49 changes: 34 additions & 15 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,22 +215,13 @@ def execute
end
end

# Undeclared params are looked for once the validators have run, so that a
# +JSON+ param sent as a String is looked into as the Hash it is coerced to.
def run_validators(request:)
validators = @state.inheritable_setting.route_validations
return if validators.empty?

validation_exceptions = nil

Grape::Validations::ParamScopeTracker.track do
instrument_run_validators(validators, request) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation, Grape::Exceptions::ValidationArrayErrors => e
(validation_exceptions ||= []) << e
break if validator.fail_fast?
end
end
end
validation_exceptions = validation_exceptions_from(validators, request) unless validators.empty?
undeclared = @state.undeclared_params&.errors(request)
validation_exceptions = [*validation_exceptions, *undeclared] if undeclared

raise Grape::Exceptions::ValidationErrors.new(exceptions: validation_exceptions, headers: header) if validation_exceptions
end
Expand Down Expand Up @@ -281,6 +272,23 @@ def answer_allowed_methods
''
end

def validation_exceptions_from(validators, request)
validation_exceptions = nil

Grape::Validations::ParamScopeTracker.track do
instrument_run_validators(validators, request) do
validators.each do |validator|
validator.validate(request)
rescue Grape::Exceptions::Validation, Grape::Exceptions::ValidationArrayErrors => e
(validation_exceptions ||= []) << e
break if validator.fail_fast?
end
end
end

validation_exceptions
end

# The route block's value, run once the params are validated between the
# +before_validation+ and +after_validation+ filters.
def validate_and_execute(callbacks)
Expand Down Expand Up @@ -339,11 +347,22 @@ def compile!
@state = @state.with(
app:,
callbacks: callbacks.each_value.all?(&:empty?) ? nil : callbacks.freeze,
build_params_with: inheritable_setting.build_params_with
build_params_with: inheritable_setting.build_params_with,
undeclared_params: build_undeclared_params
)
@prototype = build_prototype(build_helpers)
end

def build_undeclared_params
return if Grape.config.allow_undeclared_params

Grape::Validations::UndeclaredParams.new(
declared_params: inheritable_setting.route_declared_params,
contract_key_maps: inheritable_setting.contract_key_maps,
path_params: routes.flat_map { |route| route.pattern.to_regexp.names }
)
end

# The object each request is copied from: this endpoint's State, and none
# of the route-building state (+@prototype+, +@endpoints+, +@namespace+,
# +@routes+) the request path never reads.
Expand Down
10 changes: 6 additions & 4 deletions lib/grape/endpoint/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ module Grape
class Endpoint
# Everything an endpoint's requests read from it and none of them changes.
# Each request's copy of the endpoint holds this and its own state, and
# nothing else of the endpoint. +app+, +callbacks+ and +build_params_with+
# are filled in by Endpoint#compile!.
State = Data.define(:inheritable_setting, :options, :config, :source, :options_route_enabled, :app, :callbacks, :build_params_with) do
def initialize(inheritable_setting:, options:, config:, source:, options_route_enabled: false, app: nil, callbacks: nil, build_params_with: nil)
# nothing else of the endpoint. +app+, +callbacks+, +build_params_with+ and
# +undeclared_params+ are filled in by Endpoint#compile!.
State = Data.define(:inheritable_setting, :options, :config, :source, :options_route_enabled, :app, :callbacks, :build_params_with,
:undeclared_params) do
def initialize(inheritable_setting:, options:, config:, source:, options_route_enabled: false, app: nil, callbacks: nil,
build_params_with: nil, undeclared_params: nil)
super
end
end
Expand Down
1 change: 1 addition & 0 deletions lib/grape/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ en:
presence: 'is missing'
regexp: 'is invalid'
same_as: 'is not the same as %{parameter}'
undeclared: 'is not allowed'
unknown_auth_strategy: 'unknown auth strategy: %{strategy}'
unknown_error_formatter: 'unknown error formatter: %{error_formatter_type}'
unknown_options: 'unknown options: %{options}'
Expand Down
7 changes: 7 additions & 0 deletions lib/grape/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ def cookies?
!@cookies.nil?
end

# Whether the client sent params, in a query string or a body. Without
# either, +params+ holds the route's captures alone, and finding that out
# does not need them built.
def params_sent?
!query_string.empty? || form_params?
end

private

# Resolved on first use rather than in the constructor: a request that never
Expand Down
124 changes: 124 additions & 0 deletions lib/grape/validations/undeclared_params.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# frozen_string_literal: true

module Grape
module Validations
# The params of a request that its endpoint did not declare, answered 400
# when +Grape.config.allow_undeclared_params+ is false.
#
# The names an endpoint declares -- with +requires+ and +optional+, in its
# own +params+ block and its namespaces', as a +contract+'s keys, or as a
# segment of its path -- are folded into a tree once, when the endpoint
# compiles. Each name maps to the tree of the names declared under it, or
# to nil when nothing is: a param declared without a block (a +File+, a
# +type: Hash+ or +JSON+ without one, a +oneof:+) takes whatever it is
# given, and is left to its validators.
class UndeclaredParams
extend Grape::Util::FreezeOnNew

# Read by Grape itself rather than by an endpoint: the formatter takes
# the response format from it. See Middleware::Formatter#format_from_query.
GRAPE_PARAMS = [Grape::Middleware::Formatter::FORMAT_PARAM].freeze

# What the tree answers for a name it does not hold, where nil is a name
# declared without children.
UNDECLARED = Object.new.freeze
private_constant :UNDECLARED

# @param declared_params [Array<ParamsScope::Attr>] the endpoint's
# declared params, its namespaces' included
# @param contract_key_maps [Array<Dry::Schema::KeyMap>] the keys of the
# endpoint's contracts
# @param path_params [Array<String>] the names its routes capture
def initialize(declared_params:, contract_key_maps:, path_params:)
tree = {}
[*GRAPE_PARAMS, *path_params].each { |name| tree[name] = nil }
declared_params.each { |attr| declared_entries(attr.key).each { |entry| add(tree, *entry) } }
contract_key_maps.each { |key_map| key_map.dump.each { |spec| add(tree, *contract_entry(spec)) } }
@tree = Grape::Util::DeepFreeze.deep_freeze(tree)
end

# @param request [Grape::Request]
# @return [Array<Grape::Exceptions::Validation>, nil] one error for each
# param of +request+ that is not declared, nil when there is none
def errors(request)
return unless request.params_sent?

undeclared_in(request.params, @tree)&.map do |path|
Grape::Exceptions::Validation.new(params: full_name(path), message: :undeclared)
end
end

private

# +children+ are the +[name, children]+ entries declared under +name+, or
# nil when it is declared without any. Two declarations of a name with
# children add up, and one without any accepts anything, whichever comes
# first.
def add(tree, name, children)
return tree[name] = nil unless children

subtree = tree.key?(name) ? tree[name] : (tree[name] = {})
children.each { |entry| add(subtree, *entry) } if subtree
end

# The entries of a ParamsScope::Attr's key: a name, or a Hash of names
# to the Attrs declared under them.
def declared_entries(key)
return [[key.to_s, nil]] unless key.is_a?(Hash)

key.map { |name, attrs| [name.to_s, attrs.flat_map { |attr| declared_entries(attr.key) }] }
end

# The entry of a key in Dry::Schema::KeyMap#dump's format: a name, a
# +{ name => [spec] }+ Hash, or a +[name, [spec]]+ pair for an Array of
# Hashes.
def contract_entry(spec)
return [spec.to_s, nil] unless spec.is_a?(Hash) || spec.is_a?(Array)

name, children = spec.is_a?(Hash) ? spec.first : spec
[name.to_s, children.map { |child| contract_entry(child) }]
end

# The paths, relative to +value+, of what +value+ holds that +tree+ does
# not declare, as Arrays of names and indexes; nil when there is none.
# They are built only once one is found, so a request that declares
# everything it sends allocates nothing here.
def undeclared_in(value, tree)
return undeclared_in_elements(value, tree) if value.is_a?(Array)

undeclared_in_hash(value, tree) if value.is_a?(Hash)
end

def undeclared_in_hash(hash, tree)
found = nil
hash.each_pair do |key, value|
name = key.is_a?(Symbol) ? key.name : key
subtree = tree.fetch(name, UNDECLARED)
next unless subtree

paths = subtree.equal?(UNDECLARED) ? [[name]] : prefixed(name, undeclared_in(value, subtree))
(found ||= []).concat(paths) if paths
end
found
end

def undeclared_in_elements(elements, tree)
found = nil
elements.each_with_index do |element, index|
paths = prefixed(index, undeclared_in(element, tree))
(found ||= []).concat(paths) if paths
end
found
end

def prefixed(segment, paths)
paths&.each { |path| path.unshift(segment) }
end

def full_name(path)
first, *rest = path
rest.empty? ? first.to_s : "#{first}[#{rest.join('][')}]"
end
end
end
end
Loading
Loading