Skip to content

feature(#2385): Param with multiple acceptable Hash Types#2661

Open
jcagarcia wants to merge 4 commits intoruby-grape:masterfrom
jcagarcia:issue-2385
Open

feature(#2385): Param with multiple acceptable Hash Types#2661
jcagarcia wants to merge 4 commits intoruby-grape:masterfrom
jcagarcia:issue-2385

Conversation

@jcagarcia
Copy link
Contributor

Tries to resolve #2385 😄

Summary

This PR implements support for defining parameters that accept multiple different hash structures (polymorphic hashes) using the new hash_schema helper with the types option. This addresses the feature request in #2385, which asked for a cleaner way to validate parameters that can have multiple valid schemas.

Previously, developers had to use a combination of optional fields with complex mutually_exclusive and given constraints, which was difficult to read and maintain. This new approach provides a clear, declarative syntax for defining multiple acceptable hash structures.

What's New

  • hash_schema helper: A new DSL method that defines a Hash schema with specific validation rules
  • Multiple schema support: Use the types option with an array of hash_schema definitions
  • Comprehensive validation: Validates that the parameter matches at least one of the provided schemas
  • Type coercion: Supports automatic type coercion within each schema (Integer, Float, Boolean, etc.)
  • Nested structures: Full support for nested hash schemas with required and optional fields
  • Better error messages: Reports specific validation errors from the closest-matching schema

Usage Examples

Basic Usage - Multiple Hash Schemas

params do
  requires :value, desc: 'Price value', types: [
    hash_schema { requires :fixed_price, type: Float },
    hash_schema do
      requires :time_unit, type: String
      requires :rate, type: Float
    end
  ]
end
post '/pricing' do
  puts params[:value]
end
  Valid Requests:
  #Matches first schema
  { value: { fixed_price: 100.0 } }

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

Nested Hash Schemas

params do
  requires :options, types: [
    hash_schema do
      requires :form, type: Hash do
        requires :colour, type: String
        requires :font, type: String
        optional :size, type: Integer
      end
    end,
    hash_schema do
      requires :api, type: Hash do
        requires :authenticated, type: Grape::API::Boolean
      end
    end
  ]
end
  Valid Requests:
  # Matches first schema
  { options: { form: { colour: 'red', font: 'Arial' } } }

  # Matches first schema with optional field
  { options: { form: { colour: 'blue', font: 'Helvetica', size: 12 } } }

  # Matches second schema
  { options: { api: { authenticated: true } } }

Error Reporting

When validation fails, the error messages are clear and specific:

  # Missing field in nested structure
  { options: { form: { colour: 'red' } } }
  # => { "error": "options[form][font] is missing" }

  # Multiple missing fields - all reported at once
  { options: { form: {} } }
  # => { "error": "options[form][colour] is missing, options[form][font] is missing" }

  # Doesn't match any schema
  { value: { invalid_key: 'test' } }
  # => { "error": "value does not match any of the allowed schemas" }

All tests pass and the implementation maintains backward compatibility with existing Grape functionality.


cc @mia-n @dblock - This implements the feature you requested in #2385. Would love to hear your feedback!

@github-actions
Copy link

github-actions bot commented Mar 6, 2026

Danger Report

No issues found.

View run

@jcagarcia
Copy link
Contributor Author

jcagarcia commented Mar 6, 2026

I can see some failed tests using Ruby 4. I'll take a look 👀 Also, rubocop failing.

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 }
]

@ericproulx
Copy link
Contributor

Hello @jcagarcia I was wondering if this would already help.

@jcagarcia
Copy link
Contributor Author

jcagarcia commented Mar 9, 2026

Hey @ericproulx , I think that the aim of this solution (and the requested feature) was to have an array of different hash schemas without the need of defining multiple optional properties (polymorphism). I would say that dry-schemas can't offer that polymorphism that we are trying to achieve in this feature.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Param with multiple acceptable Hash Types

3 participants