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
20 changes: 20 additions & 0 deletions .rubocop_todo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,31 @@
# Note that changes in the inspected code, or installation of new
# versions of RuboCop, may require this file to be generated again.

# Offense count: 2
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/ClassLength:
Exclude:
- 'lib/grape/validations/params_scope.rb'

# Offense count: 1
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/CyclomaticComplexity:
Exclude:
- 'lib/grape/validations/types/hash_schema.rb'

# Offense count: 2
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Exclude:
- 'lib/grape/endpoint.rb'
- 'lib/grape/validations/types/hash_schema.rb'

# Offense count: 2
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity:
Exclude:
- 'lib/grape/validations/params_scope.rb'
- 'lib/grape/validations/types/hash_schema.rb'

# Offense count: 18
# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* [#2656](https://github.com/ruby-grape/grape/pull/2656): Remove useless instance_variable_defined? checks - [@ericproulx](https://github.com/ericproulx).
* [#2619](https://github.com/ruby-grape/grape/pull/2619): Remove TOC from README.md and danger-toc check - [@alexanderadam](https://github.com/alexanderadam).
* [#2663](https://github.com/ruby-grape/grape/pull/2663): Refactor `ParamsScope` and `Parameters` DSL to use named kwargs - [@ericproulx](https://github.com/ericproulx).
* [#2661](https://github.com/ruby-grape/grape/pull/2661): Param with multiple acceptable Hash Types - [@jcagarcia](https://github.com/jcagarcia).
* Your contribution here.

#### Fixes
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,62 @@ end
client.get('/', status_codes: %w(1 two)) # => [1, "two"]
```

#### Multiple Hash Schemas

When you need to accept different hash structures for the same parameter (e.g., for polymorphic data), you can use `hash_schema` with the `types` option. Each schema defines a different valid structure:

```ruby
params do
requires :value, types: [
hash_schema { requires :fixed_price, type: Float },
hash_schema { requires :time_unit, type: String; requires :rate, type: Float }
Copy link
Member

@dblock dblock Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would omitting hash_schema be equivalent and clearer and apply to all parameters? Isn't it just "one of the types"?

requires :value, types: [
    { requires :fixed_price, type: Float },
    { requires :time_unit, type: String; requires :rate, type: Float }
]

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @dblock thanks for taking a look!

While that would be ideal, I think it's unfortunately not technically possible. The requires/optional keywords inside schema definitions are DSL methods and plain Hash literals are just data structures - they can't contain executable DSL code.

At the very beginning I was just defining the HashSchema class so you need to do something like

requires :value, types: [
  HashSchema.new { requires :fixed_price, type: Float }
]

However, I decided to create a new DSL parameter called hash_schema to improve the readability.

Do you see any other approaches that could make the syntax cleaner within these technical constraints, or does the current hash_schema approach seem reasonable to you?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean requires could parse types and implicitly transform hashes into HashSchema.new, at load time, no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @dblock thanks for reviewing it again,

I would say that { requires :fixed_price, type: Float } is not valid Ruby syntax, because a plan Hash can't contain method calls like that.

Is true that requires could parse the types array at load time and implicitly transform plain Hash literals into HashSchema instances. That part is doable.

The limitation I see is about expressiveness: a plain Hash can only represent a flat mapping of key → type, with no way to distinguish required vs optional keys, nested schemas, or any other per-key options.

I think that with the proposed solution of hash_schema we can have much more customisation and is keeping the same structure.

Make sense?

Copy link
Member

@dblock dblock Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am confused why we are able to handle requires: :value, types: at the parent level, but not in the inner level in your example? Can we omit the {} and make an array of requires and optional (arrays)? But maybe that's worse ....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the difference is that you are talking about requires: (key inside a hash) but this is the dsl method requires, that cannot be used inside a plain hash.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another crazy idea.

requires :value, types: [
   either { requires :fixed_price, type: Float },
   or { requires :time_unit, type: String; requires :rate, type: Float }
]

I also don't know if it's better.

@ericproulx Do you have naming preferences or other ideas? You can break the tie, I'll side with your opinion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @ericproulx , any suggestion or feedback about this? 😄 thanks!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the either, or style. Intention is clear. I need to think about the whole thing.

Copy link
Contributor Author

@jcagarcia jcagarcia Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what about more than 2 types, something like this? Multiple ors?

requires :value, types: [
   either { requires :fixed_price, type: Float },
   or { requires :time_unit, type: String; requires :rate, type: Float },
   or { requires :another_param, type: String; requires :rate, type: Float },
   or { requires :another_param, type: String; requires :rate, type: Float },
   or { requires :another_param, type: String; requires :rate, type: Float },
   or { requires :another_param, type: String; requires :rate, type: Float }
]

]
end
post '/pricing' do
# params[:value] will be validated against each schema until one matches
params[:value]
end
```

**Valid Requests:**

```ruby
# Matches first schema
{ value: { fixed_price: 100.0 } }

# Matches second schema
{ value: { time_unit: 'hour', rate: 50.0 } }
```

**Nested Hash Schemas:**

Hash schemas also support nested structures with multiple required fields. When validation fails with multiple errors, all errors are reported together:

```ruby
params do
requires :options, types: [
hash_schema {
requires :form, type: Hash do
requires :colour, type: String
requires :font, type: String
end
},
hash_schema {
requires :api, type: Hash do
requires :authenticated, type: Grape::API::Boolean
end
}
]
end

# Valid request matching first schema
{ options: { form: { colour: 'red', font: 'Arial' } } }

# Invalid request with multiple missing fields - all errors reported at once
{ options: { form: {} } }
# => { "error": "options[form][colour] is missing, options[form][font] is missing" }
```

### Validation of Nested Parameters

Parameters can be nested using `group` or by calling `requires` or `optional` with a block.
Expand Down
18 changes: 18 additions & 0 deletions lib/grape/dsl/parameters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,24 @@ def declared_param?(param)

alias group requires

# Define a hash schema for use with multiple hash type variants.
# Used with the +types+ option to allow a parameter to accept one of several
# different hash structures.
#
# @param block [Proc] the validation block defining the Hash schema
# @return [Grape::Validations::Types::HashSchema]
# @example
#
# params do
# requires :value, types: [
# hash_schema { requires :fixed_price, type: Float },
# hash_schema { requires :time_unit, type: String; requires :rate, type: Float }
# ]
# end
def hash_schema(&)
Grape::Validations::Types::HashSchema.new(&)
end

class EmptyOptionalValue; end # rubocop:disable Lint/EmptyClass

def map_params(params, element, is_array = false)
Expand Down
14 changes: 13 additions & 1 deletion lib/grape/validations/params_scope.rb
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,12 @@ def validates(attrs, validations)
# type casted values
coerce_type validations, attrs, required, opts

# If we have hash schemas, validate against them
if validations.key?(:hash_schemas)
validate('multiple_hash_schema', validations[:hash_schemas], attrs, required, opts)
validations.delete(:hash_schemas)
end

validations.each do |type, options|
# Don't try to look up validators for documentation params that don't have one.
next if RESERVED_DOCUMENTATION_KEYWORDS.include?(type)
Expand Down Expand Up @@ -397,9 +403,15 @@ def infer_coercion(validations)

coerce_type = validations[:coerce]

# Special case - when the types array contains HashSchema instances
if Types.multiple_hash_schemas?(coerce_type)
# Store schemas for validation
validations[:hash_schemas] = coerce_type
# Set coerce to Hash so we validate it's a Hash type, but don't try to coerce the schemas
validations[:coerce] = Hash
# Special case - when the argument is a single type that is a
# variant-type collection.
if Types.multiple?(coerce_type) && validations.key?(:type)
elsif Types.multiple?(coerce_type) && validations.key?(:type)
validations[:coerce] = Types::VariantCollectionCoercer.new(
coerce_type,
validations.delete(:coerce_with)
Expand Down
8 changes: 8 additions & 0 deletions lib/grape/validations/types.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ def multiple?(type)
(type.is_a?(Array) || type.is_a?(Set)) && type.size > 1
end

# Does the type list contain HashSchema instances?
#
# @param types [Array] type list to check
# @return [Boolean] +true+ if the list contains HashSchema instances
def multiple_hash_schemas?(types)
types.is_a?(Array) && types.any?(Grape::Validations::Types::HashSchema)
end

# Does Grape provide special coercion and validation
# routines for the given class? This does not include
# automatic handling for primitives, structures and
Expand Down
209 changes: 209 additions & 0 deletions lib/grape/validations/types/hash_schema.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
# frozen_string_literal: true

module Grape
module Validations
module Types
# Represents a Hash type with a specific schema defined by a block.
# Used to support multiple Hash type variants in the types option.
#
# @example
# params do
# requires :value, types: [
# HashSchema.new { requires :fixed_price, type: Float },
# HashSchema.new { requires :time_unit, type: String; requires :rate, type: Float }
# ]
# end
class HashSchema
attr_reader :block

# Result object containing validation details
class ValidationResult
attr_reader :errors, :matched_keys

def initialize
@errors = []
@matched_keys = 0
@success = true
end

def add_error(path, message)
@errors << { path: path, message: message }
@success = false
end

def increment_matched_keys
@matched_keys += 1
end

def success?
@success
end

# Calculate a score for this validation attempt
# Higher score means closer to being valid
def score
# Prioritize schemas that matched more keys
# Even if they failed validation, matching keys suggests intent
(matched_keys * 100) - errors.length
end

def valid?
@success
end
end

# @param block [Proc] the validation block defining the Hash schema
def initialize(&block)
raise ArgumentError, 'HashSchema requires a block' unless block

@block = block
@schema_structure = nil
end

# Parses the schema block to extract the validation structure
def parse_schema
return @schema_structure if @schema_structure

@schema_structure = { required: {}, optional: {} }

# Create a mock scope that captures the schema structure
parser = SchemaParser.new(@schema_structure)
parser.instance_eval(&@block)

@schema_structure
end

# Validates a hash value against this schema and returns detailed results
# @param hash_value [Hash] the hash to validate
# @param attr_name [Symbol] the parameter name
# @param api [Grape::API] the API instance
# @param parent_scope [ParamsScope] the parent scope
# @return [ValidationResult] detailed validation result
def validate_hash(hash_value, _attr_name, _api, _parent_scope)
result = ValidationResult.new

unless hash_value.is_a?(Hash)
result.add_error([], 'must be a hash')
return result
end

schema = parse_schema
validate_structure(hash_value, schema, [], result)

result
end

private

def validate_structure(hash_value, schema, path, result)
# Check all required fields
schema[:required].each do |key, config|
# Check if key exists (as symbol or string)
actual_key = hash_value.key?(key) ? key : key.to_s
value = hash_value[actual_key]

if value.nil?
result.add_error(path + [key], 'is missing')
next
end

# Track that this key was present
result.increment_matched_keys if path.empty?

# If there's a type, validate and coerce it
if config[:type]
coerced_value = coerce_value(value, config[:type])
if coerced_value.is_a?(Types::InvalidValue)
result.add_error(path + [key], 'is invalid')
next
else
# Update the hash with the coerced value
hash_value[actual_key] = coerced_value
end
end

# If there's a nested schema, validate recursively
validate_structure(hash_value[actual_key], config[:schema], path + [key], result) if config[:schema]
end

# Validate optional fields if present
schema[:optional].each do |key, config|
actual_key = if hash_value.key?(key)
key
else
(hash_value.key?(key.to_s) ? key.to_s : nil)
end
next if actual_key.nil?

value = hash_value[actual_key]
next if value.nil?

# Track that this key was present
result.increment_matched_keys if path.empty?

# If there's a type, validate and coerce it
if config[:type]
coerced_value = coerce_value(value, config[:type])
if coerced_value.is_a?(Types::InvalidValue)
result.add_error(path + [key], 'is invalid')
next
else
# Update the hash with the coerced value
hash_value[actual_key] = coerced_value
end
end

# If there's a nested schema, validate recursively
validate_structure(hash_value[actual_key], config[:schema], path + [key], result) if config[:schema]
end
end

def coerce_value(value, type)
# If it's already the right type and Hash, no coercion needed
return value if type == Hash && value.is_a?(Hash)

# Try coercion
coercer = Types.build_coercer(type)
coercer.call(value)
rescue StandardError
Types::InvalidValue.new
end

# Helper class to parse schema definition blocks
class SchemaParser
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's room for optimization here. requires and optional are doing the same thing but write in a different key.

Also, the SchemaParser usage is always

schema = { required: {}, optional: {} }
parser = SchemaParser.new(schema)
parser.instance_eval(&block)
@schema_structure ...

I think we could encapsulate the instance_eval call in a method and not create an instance .new every time. I'll think about it this weekend.

def initialize(schema_structure)
@schema_structure = schema_structure
end

def requires(key, type: nil, **_opts, &block)
if block
# Nested schema
nested_schema = { required: {}, optional: {} }
parser = SchemaParser.new(nested_schema)
parser.instance_eval(&block)
@schema_structure[:required][key] = { type: type, schema: nested_schema }
else
@schema_structure[:required][key] = { type: type }
end
end

def optional(key, type: nil, **_opts, &block)
if block
# Nested schema
nested_schema = { required: {}, optional: {} }
parser = SchemaParser.new(nested_schema)
parser.instance_eval(&block)
@schema_structure[:optional][key] = { type: type, schema: nested_schema }
else
@schema_structure[:optional][key] = { type: type }
end
end

def group(*args, &)
# Ignore group for now
end
end
end
end
end
end
Loading