diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c3201f44a2..81352673e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,7 @@ jobs: matrix: ruby: ['2.7', '3.0', '3.1', '3.2', '3.3'] gemfile: [rack_2_0, rack_3_0, rails_6_0, rails_6_1, rails_7_0, rails_7_1] + integration_only: [false] include: - ruby: '2.7' gemfile: rack_1_0 @@ -32,6 +33,9 @@ jobs: gemfile: multi_json - ruby: '2.7' gemfile: multi_xml + - ruby: '3.3' + gemfile: no_dry_validation + integration_only: true runs-on: ubuntu-latest env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile @@ -45,6 +49,7 @@ jobs: bundler-cache: true - name: Run tests + if: ${{ matrix.integration_only == false }} run: bundle exec rake spec - name: Run tests (spec/integration/eager_load) @@ -70,6 +75,10 @@ jobs: if: ${{ matrix.gemfile == 'rack_3_0' }} run: bundle exec rspec spec/integration/rack/v3 + - name: Run tests (spec/integration/no_dry_validation) + if: ${{ matrix.gemfile == 'no_dry_validation' }} + run: bundle exec rspec spec/integration/no_dry_validation + - name: Coveralls uses: coverallsapp/github-action@master with: diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 370f887e4a..be369f103b 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -228,7 +228,7 @@ RSpec/ExpectInHook: - 'spec/grape/api_spec.rb' - 'spec/grape/validations/validators/values_spec.rb' -# Offense count: 47 +# Offense count: 48 # Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly. # Include: **/*_spec*rb*, **/spec/**/* RSpec/FilePath: @@ -278,6 +278,7 @@ RSpec/FilePath: - 'spec/integration/eager_load/eager_load_spec.rb' - 'spec/integration/multi_json/json_spec.rb' - 'spec/integration/multi_xml/xml_spec.rb' + - 'spec/integration/no_dry_validation/no_dry_validation_spec.rb' - 'spec/integration/rack/v2/headers_spec.rb' - 'spec/integration/rack/v3/headers_spec.rb' @@ -341,7 +342,7 @@ RSpec/MissingExampleGroupArgument: Exclude: - 'spec/grape/middleware/exception_spec.rb' -# Offense count: 788 +# Offense count: 804 # Configuration parameters: Max. RSpec/MultipleExpectations: Exclude: @@ -392,6 +393,7 @@ RSpec/MultipleExpectations: - 'spec/grape/util/reverse_stackable_values_spec.rb' - 'spec/grape/util/stackable_values_spec.rb' - 'spec/grape/validations/attributes_doc_spec.rb' + - 'spec/grape/validations/contract_scope_spec.rb' - 'spec/grape/validations/instance_behaivour_spec.rb' - 'spec/grape/validations/params_scope_spec.rb' - 'spec/grape/validations/types/array_coercer_spec.rb' @@ -411,6 +413,7 @@ RSpec/MultipleExpectations: - 'spec/grape/validations/validators/same_as_spec.rb' - 'spec/grape/validations/validators/values_spec.rb' - 'spec/grape/validations_spec.rb' + - 'spec/integration/no_dry_validation/no_dry_validation_spec.rb' - 'spec/shared/versioning_examples.rb' # Offense count: 38 @@ -557,7 +560,7 @@ RSpec/ScatteredSetup: - 'spec/grape/util/inheritable_setting_spec.rb' - 'spec/grape/validations_spec.rb' -# Offense count: 47 +# Offense count: 48 # Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. # Include: **/*_spec.rb RSpec/SpecFilePathFormat: @@ -608,6 +611,7 @@ RSpec/SpecFilePathFormat: - 'spec/integration/eager_load/eager_load_spec.rb' - 'spec/integration/multi_json/json_spec.rb' - 'spec/integration/multi_xml/xml_spec.rb' + - 'spec/integration/no_dry_validation/no_dry_validation_spec.rb' - 'spec/integration/rack/v2/headers_spec.rb' - 'spec/integration/rack/v3/headers_spec.rb' diff --git a/Appraisals b/Appraisals index a9d68682db..ad4ce12c02 100644 --- a/Appraisals +++ b/Appraisals @@ -1,10 +1,15 @@ # frozen_string_literal: true -appraise 'rails-5' do - gem 'rails', '~> 5.2' +customize_gemfiles do + { + single_quotes: true, + heading: "frozen_string_literal: true + +This file was generated by Appraisal" + } end -appraise 'rails-6' do +appraise 'rails-6-0' do gem 'rails', '~> 6.0.0' end @@ -12,8 +17,12 @@ appraise 'rails-6-1' do gem 'rails', '~> 6.1' end -appraise 'rails-7' do - gem 'rails', '~> 7.0' +appraise 'rails-7-0' do + gem 'rails', '~> 7.0.0' +end + +appraise 'rails-7-1' do + gem 'rails', '~> 7.1.0' end appraise 'rails-edge' do @@ -32,14 +41,20 @@ appraise 'multi_xml' do gem 'multi_xml', require: 'multi_xml' end -appraise 'rack1' do +appraise 'rack_1_0' do gem 'rack', '~> 1.0' end -appraise 'rack2' do - gem 'rack', '~> 2.0.0' +appraise 'rack_2_0' do + gem 'rack', '~> 2.0' end -appraise 'rack3' do +appraise 'rack_3_0' do gem 'rack', '~> 3.0.0' end + +appraise 'no_dry_validation' do + group :development, :test do + remove_gem 'dry-validation' + end +end diff --git a/CHANGELOG.md b/CHANGELOG.md index f007545789..4d6df9feba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ #### Features +* [#2419](https://github.com/ruby-grape/grape/pull/2419): Add the `contract` DSL - [@dgutov](https://github.com/dgutov). * [#2371](https://github.com/ruby-grape/grape/pull/2371): Use a param value as the `default` value of other param - [@jcagarcia](https://github.com/jcagarcia). * [#2377](https://github.com/ruby-grape/grape/pull/2377): Allow to use instance variables values inside `rescue_from` - [@jcagarcia](https://github.com/jcagarcia). * [#2379](https://github.com/ruby-grape/grape/pull/2379): Take into account the `route_param` type in `recognize_path` - [@jcagarcia](https://github.com/jcagarcia). diff --git a/Gemfile b/Gemfile index e7731ef6f3..544c59d3d4 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gemspec group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/README.md b/README.md index f6c8ddf227..f0ee3160f8 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ - [Pass symbols for i18n translations](#pass-symbols-for-i18n-translations) - [Overriding Attribute Names](#overriding-attribute-names) - [With Default](#with-default) + - [Using dry-validation or dry-schema](#using-dry-validation-or-dry-schema) - [Headers](#headers) - [Request](#request) - [Header Case Handling](#header-case-handling) @@ -2086,6 +2087,40 @@ params do end ``` +### Using `dry-validation` or `dry-schema` + +As an alternative to the `params` DSL described above, you can use a schema or `dry-validation` contract to describe an endpoint's parameters. This can be especially useful if you use the above already in some other parts of your application. If not, you'll need to add `dry-validation` or `dry-schema` to your `Gemfile`. + +Then call `contract` with a contract or schema defined previously: + +```rb +CreateOrdersSchema = Dry::Schema.Params do + required(:orders).array(:hash) do + required(:name).filled(:string) + optional(:volume).maybe(:integer, lt?: 9) + end +end + +# ... + +contract CreateOrdersSchema +``` + +or with a block, using the [schema definition syntax](https://dry-rb.org/gems/dry-schema/1.13/#quick-start): + +```rb +contract do + required(:orders).array(:hash) do + required(:name).filled(:string) + optional(:volume).maybe(:integer, lt?: 9) + end +end +``` + +The latter will define a coercing schema (`Dry::Schema.Params`). When using the former approach, it's up to you to decide whether the input will need coercing. + +The `params` and `contract` declarations can also be used together in the same API, e.g. to describe different parts of a nested namespace for an endpoint. + ## Headers ### Request diff --git a/gemfiles/multi_json.gemfile b/gemfiles/multi_json.gemfile index 5ed288a8de..b2b48aa270 100644 --- a/gemfiles/multi_json.gemfile +++ b/gemfiles/multi_json.gemfile @@ -8,6 +8,7 @@ gem 'multi_json', require: 'multi_json' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/multi_xml.gemfile b/gemfiles/multi_xml.gemfile index dfb2a8730f..26cd081fdb 100644 --- a/gemfiles/multi_xml.gemfile +++ b/gemfiles/multi_xml.gemfile @@ -8,6 +8,7 @@ gem 'multi_xml', require: 'multi_xml' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/no_dry_validation.gemfile b/gemfiles/no_dry_validation.gemfile new file mode 100644 index 0000000000..46f2c2c0b6 --- /dev/null +++ b/gemfiles/no_dry_validation.gemfile @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# This file was generated by Appraisal + +source 'https://rubygems.org' + +group :development, :test do + gem 'bundler' + gem 'hashie' + gem 'rake' + gem 'rubocop', '1.59.0', require: false + gem 'rubocop-performance', '1.20.1', require: false + gem 'rubocop-rspec', '2.25.0', require: false +end + +group :development do + gem 'appraisal' + gem 'benchmark-ips' + gem 'benchmark-memory' + gem 'guard' + gem 'guard-rspec' + gem 'guard-rubocop' +end + +group :test do + gem 'grape-entity', '~> 0.6', require: false + gem 'rack-jsonp', require: 'rack/jsonp' + gem 'rack-test', '< 2.1' + gem 'rspec', '< 4' + gem 'ruby-grape-danger', '~> 0.2.0', require: false + gem 'simplecov', '~> 0.21.2' + gem 'simplecov-lcov', '~> 0.8.0' + gem 'test-prof', require: false +end + +platforms :jruby do + gem 'racc' +end + +gemspec path: '../' diff --git a/gemfiles/rack_1_0.gemfile b/gemfiles/rack_1_0.gemfile index 41730950f3..c2ace22188 100644 --- a/gemfiles/rack_1_0.gemfile +++ b/gemfiles/rack_1_0.gemfile @@ -8,6 +8,7 @@ gem 'rack', '~> 1.0' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rack_2_0.gemfile b/gemfiles/rack_2_0.gemfile index 8b9cced0ed..04f3fe9479 100644 --- a/gemfiles/rack_2_0.gemfile +++ b/gemfiles/rack_2_0.gemfile @@ -8,6 +8,7 @@ gem 'rack', '~> 2.0' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rack_3_0.gemfile b/gemfiles/rack_3_0.gemfile index 59fa6a9b93..6b4712c226 100644 --- a/gemfiles/rack_3_0.gemfile +++ b/gemfiles/rack_3_0.gemfile @@ -8,6 +8,7 @@ gem 'rack', '~> 3.0.0' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rack_edge.gemfile b/gemfiles/rack_edge.gemfile index c61fb1914c..a58b7238f0 100644 --- a/gemfiles/rack_edge.gemfile +++ b/gemfiles/rack_edge.gemfile @@ -8,6 +8,7 @@ gem 'rack', github: 'rack/rack' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rails_6_0.gemfile b/gemfiles/rails_6_0.gemfile index eec0b60743..0a9d4ffdc6 100644 --- a/gemfiles/rails_6_0.gemfile +++ b/gemfiles/rails_6_0.gemfile @@ -8,6 +8,7 @@ gem 'rails', '~> 6.0.0' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile index 5daa55be9d..c1121bf807 100644 --- a/gemfiles/rails_6_1.gemfile +++ b/gemfiles/rails_6_1.gemfile @@ -8,6 +8,7 @@ gem 'rails', '~> 6.1' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rails_7_0.gemfile b/gemfiles/rails_7_0.gemfile index 9e16ddb556..17b375975c 100644 --- a/gemfiles/rails_7_0.gemfile +++ b/gemfiles/rails_7_0.gemfile @@ -8,6 +8,7 @@ gem 'rails', '~> 7.0.0' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile index 9401489313..5f3452444e 100644 --- a/gemfiles/rails_7_1.gemfile +++ b/gemfiles/rails_7_1.gemfile @@ -5,10 +5,10 @@ source 'https://rubygems.org' gem 'rails', '~> 7.1.0' -gem 'tzinfo-data', require: false group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/gemfiles/rails_edge.gemfile b/gemfiles/rails_edge.gemfile index cac5c4970b..4462cd7347 100644 --- a/gemfiles/rails_edge.gemfile +++ b/gemfiles/rails_edge.gemfile @@ -8,6 +8,7 @@ gem 'rails', github: 'rails/rails' group :development, :test do gem 'bundler' + gem 'dry-validation' gem 'hashie' gem 'rake' gem 'rubocop', '1.59.0', require: false diff --git a/lib/grape.rb b/lib/grape.rb index 6d4d90e1cc..7d3134235b 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -249,6 +249,7 @@ module Validations autoload :SingleAttributeIterator autoload :Types autoload :ParamsScope + autoload :ContractScope autoload :ValidatorFactory autoload :Base, 'grape/validations/validators/base' end diff --git a/lib/grape/dsl/inside_route.rb b/lib/grape/dsl/inside_route.rb index a5ed227ef0..ef3bdec080 100644 --- a/lib/grape/dsl/inside_route.rb +++ b/lib/grape/dsl/inside_route.rb @@ -31,11 +31,17 @@ def declared(passed_params, options = {}, declared_params = nil, params_nested_p options = options.reverse_merge(include_missing: true, include_parent_namespaces: true, evaluate_given: false) declared_params ||= optioned_declared_params(**options) - if passed_params.is_a?(Array) - declared_array(passed_params, options, declared_params, params_nested_path) - else - declared_hash(passed_params, options, declared_params, params_nested_path) + res = if passed_params.is_a?(Array) + declared_array(passed_params, options, declared_params, params_nested_path) + else + declared_hash(passed_params, options, declared_params, params_nested_path) + end + + if (key_maps = namespace_stackable(:contract_key_map)) + key_maps.each { |key_map| key_map.write(passed_params, res) } end + + res end private diff --git a/lib/grape/dsl/validations.rb b/lib/grape/dsl/validations.rb index 66aff55ef2..81d71bfebd 100644 --- a/lib/grape/dsl/validations.rb +++ b/lib/grape/dsl/validations.rb @@ -38,6 +38,19 @@ def reset_validations! def params(&block) Grape::Validations::ParamsScope.new(api: self, type: Hash, &block) end + + # Declare the contract to be used for the endpoint's parameters. + # @param contract [Class | Dry::Schema::Processor] + # The contract or schema to be used for validation. Optional. + # @yield a block yielding a new instance of Dry::Schema::Params + # subclass, allowing to define the schema inline. When the + # +contract+ parameter is a schema, it will be used as a parent. Optional. + def contract(contract = nil, &block) + raise ArgumentError, 'Either contract or block must be provided' unless contract || block + raise ArgumentError, 'Cannot inherit from contract, only schema' if block && contract.respond_to?(:schema) + + Grape::Validations::ContractScope.new(self, contract, &block) + end end end end diff --git a/lib/grape/validations/contract_scope.rb b/lib/grape/validations/contract_scope.rb new file mode 100644 index 0000000000..0255051b45 --- /dev/null +++ b/lib/grape/validations/contract_scope.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Grape + module Validations + class ContractScope + # Declare the contract to be used for the endpoint's parameters. + # @param api [API] the API endpoint to modify. + # @param contract the contract or schema to be used for validation. Optional. + # @yield a block yielding a new schema class. Optional. + def initialize(api, contract = nil, &block) + # When block is passed, the first arg is either schema or nil. + contract = Dry::Schema.Params(parent: contract, &block) if block + + if contract.respond_to?(:schema) + # It's a Dry::Validation::Contract, then. + contract = contract.new + key_map = contract.schema.key_map + else + # Dry::Schema::Processor, hopefully. + key_map = contract.key_map + end + + api.namespace_stackable(:contract_key_map, key_map) + + validator_options = { + validator_class: Validator, + opts: { schema: contract } + } + + api.namespace_stackable(:validations, validator_options) + end + + class Validator + attr_reader :schema + + def initialize(*_args, schema:) + @schema = schema + end + + # Validates a given request. + # @param request [Grape::Request] the request currently being handled + # @raise [Grape::Exceptions::ValidationArrayErrors] if validation failed + # @return [void] + def validate(request) + res = schema.call(request.params) + + if res.success? + request.params.deep_merge!(res.to_h) + return + end + + errors = [] + + res.errors.messages.each do |message| + full_name = message.path.first.to_s + + full_name += "[#{message.path[1..].join('][')}]" if message.path.size > 1 + + errors << Grape::Exceptions::Validation.new(params: [full_name], message: message.text) + end + + raise Grape::Exceptions::ValidationArrayErrors.new(errors) + end + + def fail_fast? + false + end + end + end + end +end diff --git a/spec/grape/dsl/validations_spec.rb b/spec/grape/dsl/validations_spec.rb index 66db7645af..3bb63cc97c 100644 --- a/spec/grape/dsl/validations_spec.rb +++ b/spec/grape/dsl/validations_spec.rb @@ -50,6 +50,16 @@ class Dummy expect { subject.params { raise 'foo' } }.to raise_error RuntimeError, 'foo' end end + + describe '.contract' do + it 'saves the schema instance' do + expect(subject.contract(Dry::Schema.Params)).to be_a Grape::Validations::ContractScope + end + + it 'errors without params or block' do + expect { subject.contract }.to raise_error(ArgumentError) + end + end end end end diff --git a/spec/grape/validations/contract_scope_spec.rb b/spec/grape/validations/contract_scope_spec.rb new file mode 100644 index 0000000000..c17378bc9f --- /dev/null +++ b/spec/grape/validations/contract_scope_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'pry' + +describe Grape::Validations::ContractScope do + let(:validated_params) { {} } + let(:app) do + vp = validated_params + + Class.new(Grape::API) do + after_validation do + vp.replace(params) + end + end + end + + context 'with simple schema, pre-defined' do + let(:contract) do + Dry::Schema.Params do + required(:number).filled(:integer) + end + end + + before do + app.contract(contract) + app.post('/required') + end + + it 'coerces the parameter value one level deep' do + post '/required', number: '1' + expect(last_response.status).to eq(201) + expect(validated_params).to eq('number' => 1) + end + + it 'shows expected validation error' do + post '/required' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('number is missing') + end + end + + context 'with contract class' do + let(:contract) do + Class.new(Dry::Validation::Contract) do + params do + required(:number).filled(:integer) + required(:name).filled(:string) + end + + rule(:number) do + key.failure('is too high') if value > 5 + end + end + end + + before do + app.contract(contract) + app.post('/required') + end + + it 'coerces the parameter' do + post '/required', number: '1', name: '2' + expect(last_response.status).to eq(201) + expect(validated_params).to eq('number' => 1, 'name' => '2') + end + + it 'shows expected validation error' do + post '/required', number: '6' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('name is missing, number is too high') + end + end + + context 'with nested schema' do + before do + app.contract do + required(:home).hash do + required(:address).hash do + required(:number).filled(:integer) + end + end + required(:turns).array(:integer) + end + + app.post('/required') + end + + it 'keeps unknown parameters' do + post '/required', home: { address: { number: '1', street: 'Baker' } }, turns: %w[2 3] + expect(last_response.status).to eq(201) + expected = { 'home' => { 'address' => { 'number' => 1, 'street' => 'Baker' } }, 'turns' => [2, 3] } + expect(validated_params).to eq(expected) + end + + it 'shows expected validation error' do + post '/required', home: { address: { something: 'else' } } + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('home[address][number] is missing, turns is missing') + end + end + + context 'with mixed validation sources' do + before do + app.resource :foos do + route_param :foo_id, type: Integer do + contract do + required(:number).filled(:integer) + end + post('/required') + end + end + end + + it 'combines the coercions' do + post '/foos/123/required', number: '1' + expect(last_response.status).to eq(201) + expected = { 'foo_id' => 123, 'number' => 1 } + expect(validated_params).to eq(expected) + end + + it 'shows validation error for missing' do + post '/foos/123/required' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('number is missing') + end + + it 'includes keys from all sources into declared' do + declared_params = nil + + app.after_validation do + declared_params = declared(params) + end + + post '/foos/123/required', number: '1', string: '2' + expect(last_response.status).to eq(201) + expected = { 'foo_id' => 123, 'number' => 1 } + expect(validated_params).to eq(expected.merge('string' => '2')) + expect(declared_params).to eq(expected) + end + end + + context 'with schema config validate_keys=true' do + it 'validates the whole params hash' do + app.resource :foos do + route_param :foo_id do + contract do + config.validate_keys = true + + required(:number).filled(:integer) + required(:foo_id).filled(:integer) + end + post('/required') + end + end + + post '/foos/123/required', number: '1' + expect(last_response.status).to eq(201) + expected = { 'foo_id' => 123, 'number' => 1 } + expect(validated_params).to eq(expected) + end + + it 'fails validation for any parameters not in schema' do + app.resource :foos do + route_param :foo_id, type: Integer do + contract do + config.validate_keys = true + + required(:number).filled(:integer) + end + post('/required') + end + end + + post '/foos/123/required', number: '1' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('foo_id is not allowed') + end + end +end diff --git a/spec/integration/no_dry_validation/no_dry_validation_spec.rb b/spec/integration/no_dry_validation/no_dry_validation_spec.rb new file mode 100644 index 0000000000..3954ad07a1 --- /dev/null +++ b/spec/integration/no_dry_validation/no_dry_validation_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) +require 'grape' + +describe Grape do + let(:app) do + Class.new(Grape::API) do + resource :foos do + params do + requires :type, type: String + optional :limit, type: Integer + end + get do + declared(params).to_json + end + end + end + end + + it 'executes request normally' do + get '/foos', type: 'bar', limit: 4, qux: 'tee' + + expect(last_response.status).to eq(200) + result = JSON.parse(last_response.body) + expect(result).to eq({ 'type' => 'bar', 'limit' => 4 }) + end +end