diff --git a/.coveralls.yml b/.coveralls.yml index 91600595a..fce062d2e 100644 --- a/.coveralls.yml +++ b/.coveralls.yml @@ -1 +1 @@ -service_name: travis-ci +service_name: github diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..33bb1035c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,50 @@ +## MAC OS +.DS_Store +.com.apple.timemachine.supported + +## TEXTMATE +*.tmproj +tmtags + +## EMACS +*~ +\#* +.\#* + +## REDCAR +.redcar + +## VIM +*.swp +*.swo + +## RUBYMINE +.idea + +## PROJECT::GENERAL +coverage +doc +pkg +.rvmrc +.ruby-version +.ruby-gemset +.rspec_status +.bundle +.byebug_history +dist +Gemfile.lock +gemfiles/*.lock +tmp +.yardoc + +## Rubinius +.rbx + +## Bundler binstubs +bin + +## ripper-tags and gem-ctags +tags + +## PROJECT::SPECIFIC +.project \ No newline at end of file diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml new file mode 100644 index 000000000..12372470c --- /dev/null +++ b/.github/workflows/danger.yml @@ -0,0 +1,21 @@ +--- +name: danger +on: pull_request + +jobs: + danger: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 100 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + - name: Run Danger + run: | + # the token is public, has public_repo scope and belongs to the grape-bot user owned by @dblock, this is ok + TOKEN=$(echo -n Z2hwX2lYb0dPNXNyejYzOFJyaTV3QUxUdkNiS1dtblFwZTFuRXpmMwo= | base64 --decode) + DANGER_GITHUB_API_TOKEN=$TOKEN bundle exec danger --verbose diff --git a/.github/workflows/edge.yml b/.github/workflows/edge.yml new file mode 100644 index 000000000..c73a44ad3 --- /dev/null +++ b/.github/workflows/edge.yml @@ -0,0 +1,47 @@ +--- +name: edge +on: workflow_dispatch +jobs: + test: + strategy: + fail-fast: false + matrix: + ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4', ruby-head, truffleruby-head, jruby-head] + gemfile: [rails_edge, rack_edge] + exclude: + - ruby: '2.7' + gemfile: rails_edge + - ruby: '3.0' + gemfile: rails_edge + runs-on: ubuntu-latest + continue-on-error: true + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run tests + run: bundle exec rake spec + + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: run-${{ matrix.ruby }}-${{ matrix.gemfile }} + parallel: true + + finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.github_token }} + parallel-finished: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..14a663818 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,104 @@ +name: test + +on: [push, pull_request] + +jobs: + lint: + name: RuboCop + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: 3.4 + bundler-cache: true + rubygems: latest + + - name: Run RuboCop + run: bundle exec rubocop + + test: + strategy: + fail-fast: false + matrix: + ruby: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] + gemfile: [Gemfile, gemfiles/rack_2_0.gemfile, gemfiles/rack_3_0.gemfile, gemfiles/rack_3_1.gemfile, gemfiles/rails_6_1.gemfile, gemfiles/rails_7_0.gemfile, gemfiles/rails_7_1.gemfile, gemfiles/rails_7_2.gemfile, gemfiles/rails_8_0.gemfile] + specs: ['spec --exclude-pattern=spec/integration/**/*_spec.rb'] + include: + - ruby: '2.7' + gemfile: gemfiles/multi_json.gemfile + specs: 'spec/integration/multi_json' + - ruby: '2.7' + gemfile: gemfiles/multi_xml.gemfile + specs: 'spec/integration/multi_xml' + - ruby: '2.7' + gemfile: gemfiles/rack_3_0.gemfile + specs: 'spec/integration/rack_3_0' + - ruby: '3.3' + gemfile: gemfiles/grape_entity.gemfile + specs: 'spec/integration/grape_entity' + - ruby: '3.3' + gemfile: gemfiles/hashie.gemfile + specs: 'spec/integration/hashie' + - ruby: '3.3' + gemfile: gemfiles/dry_validation.gemfile + specs: 'spec/integration/dry_validation' + - ruby: '3.3' + gemfile: gemfiles/rails_6_1.gemfile + specs: 'spec/integration/rails' + - ruby: '3.3' + gemfile: gemfiles/rails_7_0.gemfile + specs: 'spec/integration/rails' + - ruby: '3.3' + gemfile: gemfiles/rails_7_1.gemfile + specs: 'spec/integration/rails' + - ruby: '3.3' + gemfile: gemfiles/rails_7_2.gemfile + specs: 'spec/integration/rails' + - ruby: '3.3' + gemfile: gemfiles/rails_8_0.gemfile + specs: 'spec/integration/rails' + exclude: + - ruby: '2.7' + gemfile: gemfiles/rails_7_2.gemfile + - ruby: '3.0' + gemfile: gemfiles/rails_7_2.gemfile + - ruby: '2.7' + gemfile: gemfiles/rails_8_0.gemfile + - ruby: '3.0' + gemfile: gemfiles/rails_8_0.gemfile + - ruby: '3.1' + gemfile: gemfiles/rails_8_0.gemfile + runs-on: ubuntu-latest + env: + BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + + - name: Run Tests (${{ matrix.specs }}) + run: bundle exec rspec ${{ matrix.specs }} + + - name: Coveralls + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + flag-name: run-${{ matrix.ruby }}-${{ matrix.gemfile }} + parallel: true + + finish: + needs: test + runs-on: ubuntu-latest + steps: + - name: Coveralls Finished + uses: coverallsapp/github-action@v2 + with: + github-token: ${{ secrets.github_token }} + parallel-finished: true diff --git a/.gitignore b/.gitignore index 9e152a5d4..f9212a64a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ dist Gemfile.lock gemfiles/*.lock tmp +.yardoc ## Rubinius .rbx diff --git a/.rspec b/.rspec index 87d9ba441..27a9fb777 100644 --- a/.rspec +++ b/.rspec @@ -1,3 +1,5 @@ +--require spec_helper --color --format=documentation --order=rand +--warnings diff --git a/.rubocop.yml b/.rubocop.yml index f2d32fbfd..432e84f55 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,14 +1,24 @@ -require: - - rubocop-performance - AllCops: - TargetRubyVersion: 2.4 + NewCops: enable + TargetRubyVersion: 2.7 + SuggestExtensions: false Exclude: - vendor/**/* - bin/**/* +require: + - rubocop-performance + - rubocop-rspec + inherit_from: .rubocop_todo.yml +Layout/LineLength: + Max: 215 + +Lint/EmptyBlock: + Exclude: + - spec/**/*_spec.rb + Style/Documentation: Enabled: false @@ -18,15 +28,58 @@ Style/MultilineIfModifier: Style/RaiseArgs: Enabled: false -Style/HashEachMethods: - Enabled: true - -Style/HashTransformKeys: - Enabled: true +Style/RedundantArrayConstructor: + Enabled: false # doesn't work well with params definition -Style/HashTransformValues: - Enabled: true +Metrics/AbcSize: + Max: 45 Metrics/BlockLength: + Max: 30 Exclude: - spec/**/*_spec.rb + +Metrics/ClassLength: + Max: 305 + +Metrics/CyclomaticComplexity: + Max: 15 + +Metrics/ParameterLists: + MaxOptionalParameters: 4 + +Metrics/MethodLength: + Max: 32 + +Metrics/ModuleLength: + Max: 220 + +Metrics/PerceivedComplexity: + Max: 15 + +RSpec/ExampleLength: + Max: 60 + +RSpec/NestedGroups: + Max: 6 + +RSpec/SpecFilePathFormat: + Enabled: false + +RSpec/SpecFilePathSuffix: + Enabled: true + +RSpec/MultipleExpectations: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Max: 11 + +RSpec/ContextWording: + Enabled: false + +RSpec/MessageSpies: + EnforcedStyle: receive diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 79cfdde38..f93343852 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,231 +1,167 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2020-05-26 08:28:37 -0400 using RuboCop version 0.84.0. +# on 2025-02-23 19:41:28 UTC using RuboCop version 1.71.2. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. -# Offense count: 6 -# Cop supports --auto-correct. -Layout/ClosingHeredocIndentation: +# Offense count: 1 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: Exclude: - - 'spec/grape/api_spec.rb' - - 'spec/grape/entity_spec.rb' - -# Offense count: 71 -# Cop supports --auto-correct. -Layout/EmptyLineAfterGuardClause: - Enabled: false - -# Offense count: 27 -# Cop supports --auto-correct. -# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. -# SupportedHashRocketStyles: key, separator, table -# SupportedColonStyles: key, separator, table -# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit -Layout/HashAlignment: - Exclude: - - 'grape.gemspec' - - 'lib/grape/validations/params_scope.rb' - - 'lib/grape/validations/types/primitive_coercer.rb' - - 'spec/grape/api_spec.rb' - - 'spec/grape/entity_spec.rb' + - 'lib/grape/endpoint.rb' -# Offense count: 7 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle. -# SupportedStyles: squiggly, active_support, powerpack, unindent -Layout/HeredocIndentation: +# Offense count: 18 +# Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. +# SupportedStyles: snake_case, normalcase, non_integer +# AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 +Naming/VariableNumber: Exclude: - - 'lib/grape/router/route.rb' - - 'spec/grape/api_spec.rb' - - 'spec/grape/entity_spec.rb' + - 'spec/grape/dsl/settings_spec.rb' + - 'spec/grape/exceptions/validation_errors_spec.rb' + - 'spec/grape/validations_spec.rb' -# Offense count: 1 -Lint/AmbiguousBlockAssociation: +# Offense count: 2 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: SkipBlocks, EnforcedStyle, OnlyStaticConstants. +# SupportedStyles: described_class, explicit +RSpec/DescribedClass: Exclude: - - 'spec/grape/dsl/routing_spec.rb' + - 'spec/grape/middleware/exception_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -Lint/NonDeterministicRequireOrder: +# Offense count: 3 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: CustomTransform, IgnoredWords, DisallowedExamples. +# DisallowedExamples: works +RSpec/ExampleWording: Exclude: - - 'spec/spec_helper.rb' + - 'spec/grape/integration/global_namespace_function_spec.rb' + - 'spec/grape/validations_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -Lint/RedundantCopDisableDirective: +# Offense count: 7 +# This cop supports safe autocorrection (--autocorrect). +RSpec/ExpectActual: Exclude: - - 'lib/grape/router/attribute_translator.rb' + - '**/spec/routing/**/*' + - 'spec/grape/endpoint/declared_spec.rb' + - 'spec/grape/middleware/exception_spec.rb' -# Offense count: 2 -# Cop supports --auto-correct. -Lint/ToJSON: +# Offense count: 1 +RSpec/ExpectInHook: Exclude: - - 'spec/grape/middleware/formatter_spec.rb' - -# Offense count: 47 -# Configuration parameters: IgnoredMethods. -Metrics/AbcSize: - Max: 44 + - 'spec/grape/validations/validators/values_validator_spec.rb' # Offense count: 6 -# Configuration parameters: CountComments, ExcludedMethods. -# ExcludedMethods: refine -Metrics/BlockLength: - Max: 182 - -# Offense count: 10 -# Configuration parameters: CountComments. -Metrics/ClassLength: - Max: 305 +# Configuration parameters: Max, AllowedIdentifiers, AllowedPatterns. +RSpec/IndexedLet: + Exclude: + - 'spec/grape/exceptions/validation_errors_spec.rb' + - 'spec/grape/presenters/presenter_spec.rb' + - 'spec/shared/versioning_examples.rb' -# Offense count: 30 -# Configuration parameters: IgnoredMethods. -Metrics/CyclomaticComplexity: - Max: 14 +# Offense count: 39 +# Configuration parameters: AssignmentOnly. +RSpec/InstanceVariable: + Exclude: + - 'spec/grape/api_spec.rb' + - 'spec/grape/endpoint_spec.rb' + - 'spec/grape/middleware/base_spec.rb' + - 'spec/grape/middleware/versioner/accept_version_header_spec.rb' + - 'spec/grape/middleware/versioner/header_spec.rb' -# Offense count: 61 -# Configuration parameters: CountComments, ExcludedMethods. -Metrics/MethodLength: - Max: 36 +# Offense count: 1 +RSpec/MessageChain: + Exclude: + - 'spec/grape/middleware/formatter_spec.rb' # Offense count: 12 -# Configuration parameters: CountComments. -Metrics/ModuleLength: - Max: 220 - -# Offense count: 25 -# Configuration parameters: IgnoredMethods. -Metrics/PerceivedComplexity: - Max: 14 - -# Offense count: 3 -# Configuration parameters: EnforcedStyleForLeadingUnderscores. -# SupportedStylesForLeadingUnderscores: disallowed, required, optional -Naming/MemoizedInstanceVariableName: +RSpec/MissingExampleGroupArgument: Exclude: - - 'lib/grape/api/instance.rb' - - 'lib/grape/middleware/base.rb' - - 'spec/grape/integration/rack_spec.rb' + - 'spec/grape/middleware/exception_spec.rb' -# Offense count: 5 -# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. -# AllowedNames: io, id, to, by, on, in, at, ip, db, os, pp -Naming/MethodParameterName: +# Offense count: 12 +RSpec/RepeatedDescription: Exclude: - - 'lib/grape/endpoint.rb' - - 'lib/grape/middleware/error.rb' - - 'lib/grape/middleware/stack.rb' - 'spec/grape/api_spec.rb' + - 'spec/grape/endpoint_spec.rb' + - 'spec/grape/validations/validators/allow_blank_validator_spec.rb' + - 'spec/grape/validations/validators/values_validator_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: PreferredName. -Naming/RescuedExceptionsVariableName: +# Offense count: 6 +RSpec/RepeatedExample: Exclude: - - 'lib/grape/middleware/error.rb' + - 'spec/grape/middleware/versioner/accept_version_header_spec.rb' + - 'spec/grape/validations/validators/allow_blank_validator_spec.rb' -# Offense count: 3 -# Cop supports --auto-correct. -Performance/InefficientHashSearch: +# Offense count: 10 +RSpec/RepeatedExampleGroupDescription: + Exclude: + - 'spec/grape/api_spec.rb' + - 'spec/grape/endpoint_spec.rb' + - 'spec/grape/util/inheritable_setting_spec.rb' + - 'spec/grape/validations/validators/values_validator_spec.rb' + +# Offense count: 4 +RSpec/StubbedMock: Exclude: - - 'spec/grape/validations/validators/values_spec.rb' + - 'spec/grape/dsl/inside_route_spec.rb' + - 'spec/grape/dsl/routing_spec.rb' + - 'spec/grape/middleware/formatter_spec.rb' -# Offense count: 5 -# Cop supports --auto-correct. -Style/EmptyLambdaParameter: +# Offense count: 118 +RSpec/SubjectStub: Exclude: + - 'spec/grape/api_spec.rb' - 'spec/grape/dsl/callbacks_spec.rb' + - 'spec/grape/dsl/helpers_spec.rb' + - 'spec/grape/dsl/inside_route_spec.rb' - 'spec/grape/dsl/middleware_spec.rb' + - 'spec/grape/dsl/parameters_spec.rb' + - 'spec/grape/dsl/request_response_spec.rb' - 'spec/grape/dsl/routing_spec.rb' - - 'spec/grape/middleware/auth/dsl_spec.rb' + - 'spec/grape/dsl/settings_spec.rb' + - 'spec/grape/middleware/base_spec.rb' + - 'spec/grape/middleware/formatter_spec.rb' + - 'spec/grape/middleware/globals_spec.rb' - 'spec/grape/middleware/stack_spec.rb' -# Offense count: 3 -# Cop supports --auto-correct. -Style/ExpandPathArguments: +# Offense count: 22 +# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. +RSpec/VerifiedDoubles: Exclude: - - 'grape.gemspec' - - 'lib/grape.rb' - - 'spec/grape/validations/validators/coerce_spec.rb' + - 'spec/grape/api_spec.rb' + - 'spec/grape/dsl/inside_route_spec.rb' + - 'spec/grape/integration/rack_sendfile_spec.rb' + - 'spec/grape/middleware/formatter_spec.rb' + - 'spec/grape/validations/multiple_attributes_iterator_spec.rb' + - 'spec/grape/validations/single_attribute_iterator_spec.rb' # Offense count: 2 -# Configuration parameters: . -# SupportedStyles: annotated, template, unannotated -Style/FormatStringToken: - EnforcedStyle: template - -# Offense count: 23 -# Cop supports --auto-correct. -Style/IfUnlessModifier: - Exclude: - - 'lib/grape/api/instance.rb' - - 'lib/grape/dsl/desc.rb' - - 'lib/grape/dsl/request_response.rb' - - 'lib/grape/dsl/routing.rb' - - 'lib/grape/dsl/settings.rb' - - 'lib/grape/endpoint.rb' - - 'lib/grape/error_formatter/json.rb' - - 'lib/grape/error_formatter/xml.rb' - - 'lib/grape/middleware/error.rb' - - 'lib/grape/middleware/formatter.rb' - - 'lib/grape/middleware/versioner/accept_version_header.rb' - - 'lib/grape/validations/params_scope.rb' - - 'lib/grape/validations/validators/base.rb' - - 'lib/grape/validations/validators/default.rb' - - 'spec/support/versioned_helpers.rb' +RSpec/VoidExpect: + Exclude: + - 'spec/grape/api_spec.rb' + - 'spec/grape/dsl/headers_spec.rb' # Offense count: 1 -Style/MethodMissingSuper: +# This cop supports unsafe autocorrection (--autocorrect-all). +Style/CombinableLoops: Exclude: - - 'lib/grape/router/attribute_translator.rb' + - 'spec/grape/endpoint_spec.rb' -# Offense count: 1 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, EnforcedStyle, IgnoredMethods. -# SupportedStyles: predicate, comparison -Style/NumericPredicate: - Exclude: - - 'spec/**/*' - - 'lib/grape/middleware/formatter.rb' - -# Offense count: 11 -# Cop supports --auto-correct. -# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods. -# AllowedMethods: present?, blank?, presence, try, try! -Style/SafeNavigation: - Exclude: - - 'lib/grape/api/instance.rb' - - 'lib/grape/dsl/desc.rb' +# Offense count: 12 +# Configuration parameters: AllowedMethods. +# AllowedMethods: respond_to_missing? +Style/OptionalBooleanParameter: + Exclude: + - 'lib/grape/api.rb' - 'lib/grape/dsl/inside_route.rb' - - 'lib/grape/dsl/request_response.rb' + - 'lib/grape/dsl/parameters.rb' - 'lib/grape/endpoint.rb' - - 'lib/grape/middleware/error.rb' - - 'lib/grape/middleware/versioner/accept_version_header.rb' - - 'lib/grape/middleware/versioner/header.rb' - -# Offense count: 2 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyleForMultiline. -# SupportedStylesForMultiline: comma, consistent_comma, no_comma -Style/TrailingCommaInHashLiteral: - Exclude: - - 'lib/grape/middleware/error.rb' - -# Offense count: 10 -# Cop supports --auto-correct. -# Configuration parameters: EnforcedStyle, MinSize, WordRegex. -# SupportedStyles: percent, brackets -Style/WordArray: - Exclude: - - 'spec/grape/validations/validators/except_values_spec.rb' - - 'spec/grape/validations/validators/values_spec.rb' - -# Offense count: 125 -# Cop supports --auto-correct. -# Configuration parameters: AutoCorrect, AllowHeredoc, AllowURI, URISchemes, IgnoreCopDirectives, IgnoredPatterns. -# URISchemes: http, https -Layout/LineLength: - Max: 215 + - 'lib/grape/serve_stream/sendfile_response.rb' + - 'lib/grape/validations/params_scope.rb' + - 'lib/grape/validations/types/array_coercer.rb' + - 'lib/grape/validations/types/custom_type_collection_coercer.rb' + - 'lib/grape/validations/types/dry_type_coercer.rb' + - 'lib/grape/validations/types/primitive_coercer.rb' + - 'lib/grape/validations/types/set_coercer.rb' diff --git a/.simplecov b/.simplecov new file mode 100644 index 000000000..dd8c5cd4d --- /dev/null +++ b/.simplecov @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +if ENV['GITHUB_USER'] # only when running CI + require 'simplecov-lcov' + SimpleCov::Formatter::LcovFormatter.config do |c| + c.report_with_single_file = true + c.single_report_path = 'coverage/lcov.info' + end + + SimpleCov.formatter = SimpleCov::Formatter::LcovFormatter +end + +SimpleCov.start do + add_filter '/spec/' +end diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 134679cfe..000000000 --- a/.travis.yml +++ /dev/null @@ -1,62 +0,0 @@ -language: ruby - -sudo: false - -# "gemfile" is required for "allow_failures" option, -# see https://docs.travis-ci.com/user/customizing-the-build/#matching-jobs-with-allow_failures -gemfile: - -matrix: - include: - - rvm: 2.7.1 - script: - - bundle exec danger - - rvm: 2.7.1 - gemfile: Gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rack1.gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rack2.gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rack_edge.gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rails_edge.gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rails_5.gemfile - - rvm: 2.7.1 - gemfile: gemfiles/rails_6.gemfile - - rvm: 2.7.1 - gemfile: Gemfile - script: - - bundle exec rspec spec/integration/eager_load - - rvm: 2.7.1 - gemfile: gemfiles/multi_json.gemfile - script: - - bundle exec rake - - bundle exec rspec spec/integration/multi_json - - rvm: 2.7.1 - gemfile: gemfiles/multi_xml.gemfile - script: - - bundle exec rake - - bundle exec rspec spec/integration/multi_xml - - rvm: 2.6.6 - gemfile: Gemfile - - rvm: 2.6.6 - gemfile: gemfiles/rails_5.gemfile - - rvm: 2.6.6 - gemfile: gemfiles/rails_6.gemfile - - rvm: 2.5.8 - gemfile: Gemfile - - rvm: 2.5.8 - gemfile: gemfiles/rails_5.gemfile - - rvm: 2.5.8 - gemfile: gemfiles/rails_6.gemfile - - rvm: ruby-head - - rvm: jruby-head - - rvm: rbx-3 - allow_failures: - - rvm: ruby-head - - rvm: jruby-head - - rvm: rbx-3 - -bundler_args: --without development diff --git a/.yardopts b/.yardopts index 51701cd72..0e82f4d97 100644 --- a/.yardopts +++ b/.yardopts @@ -1 +1,2 @@ --asset grape.png +--markup markdown diff --git a/Appraisals b/Appraisals deleted file mode 100644 index 7e17deba5..000000000 --- a/Appraisals +++ /dev/null @@ -1,33 +0,0 @@ -# frozen_string_literal: true - -appraise 'rails-5' do - gem 'rails', '~> 5.2' -end - -appraise 'rails-6' do - gem 'rails', '~> 6.0' -end - -appraise 'rails-edge' do - gem 'rails', github: 'rails/rails' -end - -appraise 'rack-edge' do - gem 'rack', github: 'rack/rack' -end - -appraise 'multi_json' do - gem 'multi_json', require: 'multi_json' -end - -appraise 'multi_xml' do - gem 'multi_xml', require: 'multi_xml' -end - -appraise 'rack1' do - gem 'rack', '~> 1.0' -end - -appraise 'rack2' do - gem 'rack', '~> 2.0' -end diff --git a/CHANGELOG.md b/CHANGELOG.md index 5054c1dac..e7ef011ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,16 +1,329 @@ -### 1.4.1 (Next) +### 2.4.0 (Next) #### Features +* [#2532](https://github.com/ruby-grape/grape/pull/2532): Update RuboCop 1.71.2 - [@ericproulx](https://github.com/ericproulx). +* [#2535](https://github.com/ruby-grape/grape/pull/2535): Delegates calls to inner objects - [@ericproulx](https://github.com/ericproulx). +* [#2537](https://github.com/ruby-grape/grape/pull/2537): Use activesupport `try` pattern - [@ericproulx](https://github.com/ericproulx). +* [#2536](https://github.com/ruby-grape/grape/pull/2536): Update normalize_path like Rails - [@ericproulx](https://github.com/ericproulx). +* [#2540](https://github.com/ruby-grape/grape/pull/2540): Introduce Params builder with symbolized short name - [@ericproulx](https://github.com/ericproulx). +* [#2550](https://github.com/ruby-grape/grape/pull/2550): Drop ActiveSupport 6.0 - [@ericproulx](https://github.com/ericproulx). +* [#2549](https://github.com/ruby-grape/grape/pull/2549): Delegate cookies management to `Grape::Request` - [@ericproulx](https://github.com/ericproulx). +* [#2554](https://github.com/ruby-grape/grape/pull/2554): Remove `Grape::Http::Headers` and `Grape::Util::Lazy::Object` - [@ericproulx](https://github.com/ericproulx). +* [#2556](https://github.com/ruby-grape/grape/pull/2556): Remove unused `Grape::Request::DEFAULT_PARAMS_BUILDER` constant - [@eriklovmo](https://github.com/eriklovmo). * Your contribution here. #### Fixes +* [#2538](https://github.com/ruby-grape/grape/pull/2538): Fix validating nested json array params - [@mohammednasser-32](https://github.com/mohammednasser-32). +* [#2543](https://github.com/ruby-grape/grape/pull/2543): Fix array allocation on mount - [@ericproulx](https://github.com/ericproulx). +* [#2546](https://github.com/ruby-grape/grape/pull/2546): Fix middleware with keywords - [@ericproulx](https://github.com/ericproulx). +* [#2547](https://github.com/ruby-grape/grape/pull/2547): Remove jsonapi related code - [@ericproulx](https://github.com/ericproulx). +* [#2548](https://github.com/ruby-grape/grape/pull/2548): Formatting from header acts like versioning from header - [@ericproulx](https://github.com/ericproulx). +* [#2552](https://github.com/ruby-grape/grape/pull/2552): Fix declared params optional array - [@ericproulx](https://github.com/ericproulx). +* [#2553](https://github.com/ruby-grape/grape/pull/2553): Improve performance of query params parsing - [@ericproulx](https://github.com/ericproulx). * Your contribution here. + +### 2.3.0 (2025-02-08) + +#### Features + +* [#2497](https://github.com/ruby-grape/grape/pull/2497): Update RuboCop to 1.66.1 - [@ericproulx](https://github.com/ericproulx). +* [#2500](https://github.com/ruby-grape/grape/pull/2500): Remove deprecated `file` method - [@ericproulx](https://github.com/ericproulx). +* [#2501](https://github.com/ruby-grape/grape/pull/2501): Remove deprecated `except` and `proc` options in values validator - [@ericproulx](https://github.com/ericproulx). +* [#2502](https://github.com/ruby-grape/grape/pull/2502): Remove deprecation `options` in `desc` - [@ericproulx](https://github.com/ericproulx). +* [#2512](https://github.com/ruby-grape/grape/pull/2512): Optimize hash alloc - [@ericproulx](https://github.com/ericproulx). +* [#2513](https://github.com/ruby-grape/grape/pull/2513): Optimize Grape::Path - [@ericproulx](https://github.com/ericproulx). +* [#2514](https://github.com/ruby-grape/grape/pull/2514): Add rails 8.0 to CI - [@ericproulx](https://github.com/ericproulx). +* [#2516](https://github.com/ruby-grape/grape/pull/2516): Dynamic registration for parsers, formatters, versioners - [@ericproulx](https://github.com/ericproulx). +* [#2518](https://github.com/ruby-grape/grape/pull/2518): Add ruby 3.4 to CI - [@ericproulx](https://github.com/ericproulx). + +#### Fixes + +* [#2504](https://github.com/ruby-grape/grape/pull/2504): Fix leaky modules in specs - [@ericproulx](https://github.com/ericproulx). +* [#2506](https://github.com/ruby-grape/grape/pull/2506): Fix fetch_formatter api_format - [@ericproulx](https://github.com/ericproulx). +* [#2507](https://github.com/ruby-grape/grape/pull/2507): Fix type: Set with values - [@nikolai-b](https://github.com/nikolai-b). +* [#2510](https://github.com/ruby-grape/grape/pull/2510): Fix ContractScope's validator inheritance - [@ericproulx](https://github.com/ericproulx). +* [#2521](https://github.com/ruby-grape/grape/pull/2521): Fixed typo in README - [@datpmt](https://github.com/datpmt). +* [#2525](https://github.com/ruby-grape/grape/pull/2525): Require logger before active_support - [@ericproulx](https://github.com/ericproulx). +* [#2524](https://github.com/ruby-grape/grape/pull/2524): Fix validators bad encoding - [@ericproulx](https://github.com/ericproulx). +* [#2530](https://github.com/ruby-grape/grape/pull/2530): Fix endpoint's status when rescue_from without a block - [@ericproulx](https://github.com/ericproulx). +* [#2529](https://github.com/ruby-grape/grape/pull/2529): Fix missing settings on mounted routes (when settings are identical) - [@Haerezis](https://github.com/Haerezis). + +### 2.2.0 (2024-09-14) + +#### Features + +* [#2475](https://github.com/ruby-grape/grape/pull/2475): Remove Grape::Util::Registrable - [@ericproulx](https://github.com/ericproulx). +* [#2484](https://github.com/ruby-grape/grape/pull/2484): Refactor versioner middlewares - [@ericproulx](https://github.com/ericproulx). +* [#2489](https://github.com/ruby-grape/grape/pull/2489): Add Rails 7.2 in CI workflow - [@ericproulx](https://github.com/ericproulx). +* [#2493](https://github.com/ruby-grape/grape/pull/2493): MFA required when releasing - [@ericproulx](https://github.com/ericproulx). + +#### Fixes + +* [#2471](https://github.com/ruby-grape/grape/pull/2471): Fix absence of original_exception and/or backtrace even if passed in error! - [@numbata](https://github.com/numbata). +* [#2478](https://github.com/ruby-grape/grape/pull/2478): Fix rescue_from with invalid response - [@ericproulx](https://github.com/ericproulx). +* [#2480](https://github.com/ruby-grape/grape/pull/2480): Fix rescue_from ValidationErrors exception - [@numbata](https://github.com/numbata). +* [#2464](https://github.com/ruby-grape/grape/pull/2464): The `length` validator only takes effect for parameters with types that support `#length` method - [@OuYangJinTing](https://github.com/OuYangJinTing). +* [#2485](https://github.com/ruby-grape/grape/pull/2485): Add `is:` param to length validator - [@dakad](https://github.com/dakad). +* [#2492](https://github.com/ruby-grape/grape/pull/2492): Fix `Grape::Endpoint#inspect` method - [@ericproulx](https://github.com/ericproulx). +* [#2496](https://github.com/ruby-grape/grape/pull/2496): Reduce object allocation when compiling - [@ericproulx](https://github.com/ericproulx). + +### 2.1.3 (2024-07-13) + +#### Fixes + +* [#2467](https://github.com/ruby-grape/grape/pull/2467): Fix repo coverage - [@ericproulx](https://github.com/ericproulx). +* [#2468](https://github.com/ruby-grape/grape/pull/2468): Align `error!` method signatures across different places - [@numbata](https://github.com/numbata). +* [#2469](https://github.com/ruby-grape/grape/pull/2469): Fix full path building for lateral scopes - [@numbata](https://github.com/numbata). + +### 2.1.2 (2024-06-28) + +#### Fixes + +* [#2459](https://github.com/ruby-grape/grape/pull/2459): Autocorrect cops - [@ericproulx](https://github.com/ericproulx). +* [#3458](https://github.com/ruby-grape/grape/pull/2458): Remove unused Grape::Util::Accept::Header - [@ericproulx](https://github.com/ericproulx). +* [#2463](https://github.com/ruby-grape/grape/pull/2463): Fix error message indices - [@ericproulx](https://github.com/ericproulx). + +### 2.1.1 (2024-06-22) + +#### Features + +* [#2450](https://github.com/ruby-grape/grape/pull/2450): Update RuboCop to 1.64.1 - [@ericproulx](https://github.com/ericproulx). + +#### Fixes + +* [#2453](https://github.com/ruby-grape/grape/pull/2453): Fix context in rescue_from - [@ericproulx](https://github.com/ericproulx). +* [#2455](https://github.com/ruby-grape/grape/pull/2455): Fix default response headers to work with Rack 3 - [@ericproulx](https://github.com/ericproulx). + +### 2.1.0 (2024/06/15) + +#### Features + +* [#2432](https://github.com/ruby-grape/grape/pull/2432): Deep merge for group parameter attributes - [@numbata](https://github.com/numbata). +* [#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). +* [#2383](https://github.com/ruby-grape/grape/pull/2383): Use regex block instead of if - [@ericproulx](https://github.com/ericproulx). +* [#2384](https://github.com/ruby-grape/grape/pull/2384): Allow to use `before/after/rescue_from` methods in any order when using `mount` - [@jcagarcia](https://github.com/jcagarcia). +* [#2390](https://github.com/ruby-grape/grape/pull/2390): Drop support for Ruby 2.6 and Rails 5 - [@ericproulx](https://github.com/ericproulx). +* [#2393](https://github.com/ruby-grape/grape/pull/2393): Optimize AttributeTranslator - [@ericproulx](https://github.com/ericproulx). +* [#2395](https://github.com/ruby-grape/grape/pull/2395): Set `max-age` to 0 when `cookies.delete` - [@ericproulx](https://github.com/ericproulx). +* [#2397](https://github.com/ruby-grape/grape/pull/2397): Add support for ruby 3.3 - [@ericproulx](https://github.com/ericproulx). +* [#2399](https://github.com/ruby-grape/grape/pull/2399): Update `rubocop` to 1.59.0, `rubocop-performance` to 1.20.1 and `rubocop-rspec` to 2.25.0 - [@ericproulx](https://github.com/ericproulx). +* [#2402](https://github.com/ruby-grape/grape/pull/2402): Grape::Deprecations will be raised when running specs - [@ericproulx](https://github.com/ericproulx). +* [#2406](https://github.com/ruby-grape/grape/pull/2406): Remove mime-types dependency in specs - [@ericproulx](https://github.com/ericproulx). +* [#2408](https://github.com/ruby-grape/grape/pull/2408): Fix params method redefined warnings - [@ericproulx](https://github.com/ericproulx). +* [#2410](https://github.com/ruby-grape/grape/pull/2410): Gem deprecations will raise a DeprecationWarning in specs - [@ericproulx](https://github.com/ericproulx). +* [#2389](https://github.com/ruby-grape/grape/pull/2389): Remove rack-accept dependency - [@ericproulx](https://github.com/ericproulx). +* [#2426](https://github.com/ruby-grape/grape/pull/2426): Drop support for rack 1.x series - [@ericproulx](https://github.com/ericproulx). +* [#2427](https://github.com/ruby-grape/grape/pull/2427): Use `rack-contrib` jsonp instead of rack-jsonp - [@ericproulx](https://github.com/ericproulx). +* [#2363](https://github.com/ruby-grape/grape/pull/2363): Replace autoload by zeitwerk - [@ericproulx](https://github.com/ericproulx). +* [#2425](https://github.com/ruby-grape/grape/pull/2425): Replace `{}` with `Rack::Header` or `Rack::Utils::HeaderHash` - [@dhruvCW](https://github.com/dhruvCW). +* [#2430](https://github.com/ruby-grape/grape/pull/2430): Isolate extensions within specific gemfile - [@ericproulx](https://github.com/ericproulx). +* [#2431](https://github.com/ruby-grape/grape/pull/2431): Drop appraisals in favor of eval_gemfile - [@ericproulx](https://github.com/ericproulx). +* [#2435](https://github.com/ruby-grape/grape/pull/2435): Use rack constants - [@ericproulx](https://github.com/ericproulx). +* [#2436](https://github.com/ruby-grape/grape/pull/2436): Update coverallsapp github-action - [@ericproulx](https://github.com/ericproulx). +* [#2434](https://github.com/ruby-grape/grape/pull/2434): Implement nested `with` support in parameter dsl - [@numbata](https://github.com/numbata). +* [#2438](https://github.com/ruby-grape/grape/pull/2438): Fix some Rack::Lint - [@ericproulx](https://github.com/ericproulx). +* [#2437](https://github.com/ruby-grape/grape/pull/2437): Add length validator - [@dhruvCW](https://github.com/dhruvCW). +* [#2445](https://github.com/ruby-grape/grape/pull/2445): Remove builder as a dependency - [@ericproulx](https://github.com/ericproulx). + +#### Fixes + +* [#2375](https://github.com/ruby-grape/grape/pull/2375): Fix setter methods for `Grape::Router::AttributeTranslator` - [@Jell](https://github.com/Jell). +* [#2370](https://github.com/ruby-grape/grape/pull/2370): Remove route_xyz method_missing deprecation - [@ericproulx](https://github.com/ericproulx). +* [#2372](https://github.com/ruby-grape/grape/pull/2372): Fix `declared` method for hash params with overlapping names - [@jcagarcia](https://github.com/jcagarcia). +* [#2373](https://github.com/ruby-grape/grape/pull/2373): Fix markdown files for following 1-line format - [@jcagarcia](https://github.com/jcagarcia). +* [#2382](https://github.com/ruby-grape/grape/pull/2382): Fix values validator for params wrapped in `with` block - [@numbata](https://github.com/numbata). +* [#2387](https://github.com/ruby-grape/grape/pull/2387): Fix rubygems version within workflows - [@ericproulx](https://github.com/ericproulx). +* [#2405](https://github.com/ruby-grape/grape/pull/2405): Fix edge workflow - [@ericproulx](https://github.com/ericproulx). +* [#2414](https://github.com/ruby-grape/grape/pull/2414): Fix Rack::Lint missing content-type - [@ericproulx](https://github.com/ericproulx). +* [#2378](https://github.com/ruby-grape/grape/pull/2378): Do not overwrite `route_param` with a regular one if they share same name - [@arg](https://github.com/arg). +* [#2444](https://github.com/ruby-grape/grape/pull/2444): Replace method_missing in endpoint - [@ericproulx](https://github.com/ericproulx). +* [#2441](https://github.com/ruby-grape/grape/pull/2441): Optimize memory alloc and retained - [@ericproulx](https://github.com/ericproulx). +* [#2449](https://github.com/ruby-grape/grape/pull/2449): Rack 3.1 fixes - [@ericproulx](https://github.com/ericproulx). + +### 2.0.0 (2023/11/11) + +#### Features + +* [#2353](https://github.com/ruby-grape/grape/pull/2353): Added Rails 7.1 support - [@ericproulx](https://github.com/ericproulx). +* [#2355](https://github.com/ruby-grape/grape/pull/2355): Set response headers based on Rack version - [@schinery](https://github.com/schinery). +* [#2360](https://github.com/ruby-grape/grape/pull/2360): Reduce gem size by removing specs - [@ericproulx](https://github.com/ericproulx). +* [#2361](https://github.com/ruby-grape/grape/pull/2361): Remove `Rack::Auth::Digest` - [@ninoseki](https://github.com/ninoseki). + +#### Fixes + +* [#2364](https://github.com/ruby-grape/grape/pull/2364): Add missing requires - [@ericproulx](https://github.com/ericproulx). +* [#2366](https://github.com/ruby-grape/grape/pull/2366): Default quality to 1.0 in the `Accept` header when omitted - [@hiddewie](https://github.com/hiddewie). +* [#2368](https://github.com/ruby-grape/grape/pull/2368): Stripping the internals of `Grape::Endpoint` when `NoMethodError` is raised - [@jcagarcia](https://github.com/jcagarcia). + +### 1.8.0 (2023/08/30) + +#### Features + +* [#2326](https://github.com/ruby-grape/grape/pull/2326): Use ActiveSupport extensions - [@ericproulx](https://github.com/ericproulx). +* [#2327](https://github.com/ruby-grape/grape/pull/2327): Use ActiveSupport deprecation - [@ericproulx](https://github.com/ericproulx). +* [#2330](https://github.com/ruby-grape/grape/pull/2330): Use ActiveSupport inflector - [@ericproulx](https://github.com/ericproulx). +* [#2331](https://github.com/ruby-grape/grape/pull/2331): Memory optimization when running validators - [@ericproulx](https://github.com/ericproulx). +* [#2332](https://github.com/ruby-grape/grape/pull/2332): Use ActiveSupport configurable - [@ericproulx](https://github.com/ericproulx). +* [#2333](https://github.com/ruby-grape/grape/pull/2333): Use custom messages in parameter validation with arity 1 - [@thedevjoao](https://github.com/TheDevJoao). +* [#2341](https://github.com/ruby-grape/grape/pull/2341): Stop yielding skip value - [@ericproulx](https://github.com/ericproulx). +* [#2342](https://github.com/ruby-grape/grape/pull/2342): Allow specifying a handler for grape_exceptions - [@mscrivo](https://github.com/mscrivo). +* [#2338](https://github.com/ruby-grape/grape/pull/2338): Fix unknown validator when using requires/optional with entity - [@mscrivo](https://github.com/mscrivo). + +#### Fixes + +* [#2339](https://github.com/ruby-grape/grape/pull/2339): Documentation and specs for remountable configuration in params - [@myxoh](https://github.com/myxoh). +* [#2328](https://github.com/ruby-grape/grape/pull/2328): Don't cache Class.instance_methods - [@byroot](https://github.com/byroot). +* [#2337](https://github.com/ruby-grape/grape/pull/2337): Fix: allow custom validators that do not end with _validator - [@ericproulx](https://github.com/ericproulx). +* [#2346](https://github.com/ruby-grape/grape/pull/2346): Adjust test expectations to conform to rack 3 - [@kbarrette](https://github.com/kbarrette). + +## 1.7.1 (2023/05/14) + +#### Features + +* [#2288](https://github.com/ruby-grape/grape/pull/2288): Dropped support for Ruby 2.5 - [@ericproulx](https://github.com/ericproulx). +* [#2288](https://github.com/ruby-grape/grape/pull/2288): Updated rubocop to 1.41.0 - [@ericproulx](https://github.com/ericproulx). +* [#2296](https://github.com/ruby-grape/grape/pull/2296): Fix cops and enables some - [@ericproulx](https://github.com/ericproulx). +* [#2302](https://github.com/ruby-grape/grape/pull/2302): Rack < 3 and update rack-test - [@ericproulx](https://github.com/ericproulx). +* [#2303](https://github.com/ruby-grape/grape/pull/2302): Rack >= 1.3.0 - [@ericproulx](https://github.com/ericproulx). +* [#2301](https://github.com/ruby-grape/grape/pull/2301): Revisit GH workflows - [@ericproulx](https://github.com/ericproulx). +* [#2311](https://github.com/ruby-grape/grape/pull/2311): Fix tests by pinning rack-test to < 2.1 - [@duffn](https://github.com/duffn). +* [#2310](https://github.com/ruby-grape/grape/pull/2310): Fix YARD docs markdown rendering - [@duffn](https://github.com/duffn). +* [#2317](https://github.com/ruby-grape/grape/pull/2317): Remove maruku and rubocop-ast as direct development/testing dependencies - [@ericproulx](https://github.com/ericproulx). +* [#2292](https://github.com/ruby-grape/grape/pull/2292): Introduce Docker to local development - [@ericproulx](https://github.com/ericproulx). +* [#2325](https://github.com/ruby-grape/grape/pull/2325): Change edge test workflows only run on demand - [@dblock](https://github.com/dblock). +* [#2324](https://github.com/ruby-grape/grape/pull/2324): Expose default in the description dsl - [@dhruvCW](https://github.com/dhruvCW). + +#### Fixes + +* [#2299](https://github.com/ruby-grape/grape/pull/2299): Fix, do not use kwargs for empty args - [@dm1try](https://github.com/dm1try). +* [#2307](https://github.com/ruby-grape/grape/pull/2307): Fixed autoloading of InvalidValue - [@fixlr](https://github.com/fixlr). +* [#2315](https://github.com/ruby-grape/grape/pull/2315): Update rspec - [@ericproulx](https://github.com/ericproulx). +* [#2319](https://github.com/ruby-grape/grape/pull/2319): Update rubocop - [@ericproulx](https://github.com/ericproulx). +* [#2323](https://github.com/ruby-grape/grape/pull/2323): Fix using endless ranges for values parameter - [@dhruvCW](https://github.com/dhruvCW). + +### 1.7.0 (2022/12/20) + +#### Features + +* [#2233](https://github.com/ruby-grape/grape/pull/2233): Added `do_not_document!` for disabling documentation to internal APIs - [@dnesteryuk](https://github.com/dnesteryuk). +* [#2235](https://github.com/ruby-grape/grape/pull/2235): Add support for Ruby 3.1 - [@petergoldstein](https://github.com/petergoldstein). +* [#2248](https://github.com/ruby-grape/grape/pull/2248): Upgraded to rspec 3.11.0 - [@dblock](https://github.com/dblock). +* [#2249](https://github.com/ruby-grape/grape/pull/2249): Split CI matrix, extract edge - [@dblock](https://github.com/dblock). +* [#2249](https://github.com/ruby-grape/grape/pull/2251): Upgraded to RuboCop 1.25.1 - [@dblock](https://github.com/dblock). +* [#2271](https://github.com/ruby-grape/grape/pull/2271): Fixed validation regression on Numeric type introduced in 1.3 - [@vasfed](https://github.com/Vasfed). +* [#2267](https://github.com/ruby-grape/grape/pull/2267): Standardized English error messages - [@dblock](https://github.com/dblock). +* [#2272](https://github.com/ruby-grape/grape/pull/2272): Added error on param init when provided type does not have `[]` coercion method, previously validation silently failed for any value - [@vasfed](https://github.com/Vasfed). +* [#2274](https://github.com/ruby-grape/grape/pull/2274): Error middleware support using rack util's symbols as status - [@dhruvCW](https://github.com/dhruvCW). +* [#2276](https://github.com/ruby-grape/grape/pull/2276): Fix exception super - [@ericproulx](https://github.com/ericproulx). +* [#2285](https://github.com/ruby-grape/grape/pull/2285), [#2287](https://github.com/ruby-grape/grape/pull/2287): Added :evaluate_given to declared(params) - [@zysend](https://github.com/zysend). + +#### Fixes + +* [#2263](https://github.com/ruby-grape/grape/pull/2263): Explicitly require `bigdecimal` and `date` - [@dblock](https://github.com/dblock). +* [#2222](https://github.com/ruby-grape/grape/pull/2222): Autoload types and validators - [@ericproulx](https://github.com/ericproulx). +* [#2232](https://github.com/ruby-grape/grape/pull/2232): Fix kwargs support in shared params definition - [@dm1try](https://github.com/dm1try). +* [#2229](https://github.com/ruby-grape/grape/pull/2229): Do not collect params in route settings - [@dnesteryuk](https://github.com/dnesteryuk). +* [#2234](https://github.com/ruby-grape/grape/pull/2234): Remove non-UTF8 characters from format before generating JSON error - [@bschmeck](https://github.com/bschmeck). +* [#2227](https://github.com/ruby-grape/grape/pull/2222): Rename `MissingGroupType` and `UnsupportedGroupType` exceptions - [@ericproulx](https://github.com/ericproulx). +* [#2244](https://github.com/ruby-grape/grape/pull/2244): Fix a breaking change in `Grape::Validations` provided in 1.6.1 - [@dm1try](https://github.com/dm1try). +* [#2250](https://github.com/ruby-grape/grape/pull/2250): Add deprecation warning for `UnsupportedGroupTypeError` and `MissingGroupTypeError` - [@ericproulx](https://github.com/ericproulx). +* [#2256](https://github.com/ruby-grape/grape/pull/2256): Raise `Grape::Exceptions::MultipartPartLimitError` from Rack when too many files are uploaded - [@bschmeck](https://github.com/bschmeck). +* [#2266](https://github.com/ruby-grape/grape/pull/2266): Fix code coverage - [@duffn](https://github.com/duffn). +* [#2284](https://github.com/ruby-grape/grape/pull/2284): Fix an unexpected backtick - [@zysend](https://github.com/zysend). + +### 1.6.2 (2021/12/30) + +#### Fixes + +* [#2219](https://github.com/ruby-grape/grape/pull/2219): Revert the changes for autoloading provided in 1.6.1 - [@dm1try](https://github.com/dm1try). + +### 1.6.1 (2021/12/28) + +#### Features + +* [#2196](https://github.com/ruby-grape/grape/pull/2196): Add support for `passwords_hashed` param for `digest_auth` - [@lHydra](https://github.com/lhydra). +* [#2208](https://github.com/ruby-grape/grape/pull/2208): Added Rails 7 support - [@ericproulx](https://github.com/ericproulx). + +#### Fixes + +* [#2206](https://github.com/ruby-grape/grape/pull/2206): Require main active_support lib before any of its extension definitions - [@annih](https://github.com/Annih). +* [#2193](https://github.com/ruby-grape/grape/pull/2193): Fixed the broken ruby-head NoMethodError spec - [@Jack12816](https://github.com/Jack12816). +* [#2192](https://github.com/ruby-grape/grape/pull/2192): Memoize the result of Grape::Middleware::Base#response - [@Jack12816](https://github.com/Jack12816). +* [#2200](https://github.com/ruby-grape/grape/pull/2200): Add validators module to all validators - [@ericproulx](https://github.com/ericproulx). +* [#2202](https://github.com/ruby-grape/grape/pull/2202): Fix random mock spec error - [@ericproulx](https://github.com/ericproulx). +* [#2203](https://github.com/ruby-grape/grape/pull/2203): Add rubocop-rspec - [@ericproulx](https://github.com/ericproulx). +* [#2207](https://github.com/ruby-grape/grape/pull/2207): Autoload Validations/Validators - [@ericproulx](https://github.com/ericproulx). +* [#2209](https://github.com/ruby-grape/grape/pull/2209): Autoload Validations/Types - [@ericproulx](https://github.com/ericproulx). + +### 1.6.0 (2021/10/04) + +#### Features + +* [#2190](https://github.com/ruby-grape/grape/pull/2190): Upgrade dev deps & drop Ruby 2.4.x support - [@dnesteryuk](https://github.com/dnesteryuk). + +#### Fixes + +* [#2176](https://github.com/ruby-grape/grape/pull/2176): Fix: OPTIONS fails if matching all routes - [@myxoh](https://github.com/myxoh). +* [#2177](https://github.com/ruby-grape/grape/pull/2177): Fix: `default` validator fails if preceded by `as` validator - [@Catsuko](https://github.com/Catsuko). +* [#2180](https://github.com/ruby-grape/grape/pull/2180): Call `super` in `API.inherited` - [@yogeshjain999](https://github.com/yogeshjain999). +* [#2189](https://github.com/ruby-grape/grape/pull/2189): Fix: rename parameters when using `:as` (behaviour and grape-swagger documentation) - [@Jack12816](https://github.com/Jack12816). + +### 1.5.3 (2021/03/07) + +#### Fixes + +* [#2161](https://github.com/ruby-grape/grape/pull/2157): Handle EOFError from Rack when given an empty multipart body - [@bschmeck](https://github.com/bschmeck). +* [#2162](https://github.com/ruby-grape/grape/pull/2162): Corrected a hash modification while iterating issue - [@Jack12816](https://github.com/Jack12816). +* [#2164](https://github.com/ruby-grape/grape/pull/2164): Fix: `coerce_with` is now called for params with `nil` value - [@braktar](https://github.com/braktar). + +### 1.5.2 (2021/02/06) + +#### Features + +* [#2157](https://github.com/ruby-grape/grape/pull/2157): Custom types can set a message to be used in the response when invalid - [@dnesteryuk](https://github.com/dnesteryuk). +* [#2145](https://github.com/ruby-grape/grape/pull/2145): Ruby 3.0 compatibility - [@ericproulx](https://github.com/ericproulx). +* [#2143](https://github.com/ruby-grape/grape/pull/2143): Enable GitHub Actions with updated RuboCop and Danger - [@anakinj](https://github.com/anakinj). + +#### Fixes + +* [#2144](https://github.com/ruby-grape/grape/pull/2144): Fix compatibility issue with activesupport 6.1 and XML serialization of arrays - [@anakinj](https://github.com/anakinj). +* [#2137](https://github.com/ruby-grape/grape/pull/2137): Fix typos - [@johnny-miyake](https://github.com/johnny-miyake). +* [#2131](https://github.com/ruby-grape/grape/pull/2131): Fix Ruby 2.7 keyword deprecation warning in validators/coerce - [@K0H205](https://github.com/K0H205). +* [#2132](https://github.com/ruby-grape/grape/pull/2132): Use #ruby2_keywords for correct delegation on Ruby <= 2.6, 2.7 and 3 - [@eregon](https://github.com/eregon). +* [#2152](https://github.com/ruby-grape/grape/pull/2152): Fix configuration method inside namespaced params - [@fsainz](https://github.com/fsainz). + +### 1.5.1 (2020/11/15) + +#### Fixes + +* [#2129](https://github.com/ruby-grape/grape/pull/2129): Fix validation error when Required Array nested inside an optional array, for Multiparam validators - [@dwhenry](https://github.com/dwhenry). +* [#2128](https://github.com/ruby-grape/grape/pull/2128): Fix validation error when Required Array nested inside an optional array - [@dwhenry](https://github.com/dwhenry). +* [#2127](https://github.com/ruby-grape/grape/pull/2127): Fix a performance issue with dependent params - [@dnesteryuk](https://github.com/dnesteryuk). +* [#2126](https://github.com/ruby-grape/grape/pull/2126): Fix warnings about redefined attribute accessors in `AttributeTranslator` - [@samsonjs](https://github.com/samsonjs). +* [#2121](https://github.com/ruby-grape/grape/pull/2121): Fix 2.7 deprecation warning in validator_factory - [@Legogris](https://github.com/Legogris). +* [#2115](https://github.com/ruby-grape/grape/pull/2115): Fix declared_params regression with multiple allowed types - [@stanhu](https://github.com/stanhu). +* [#2123](https://github.com/ruby-grape/grape/pull/2123): Fix 2.7 deprecation warning in middleware/stack - [@Legogris](https://github.com/Legogris). + +### 1.5.0 (2020/10/05) + +#### Fixes + +* [#2104](https://github.com/ruby-grape/grape/pull/2104): Fix Ruby 2.7 keyword deprecation warning - [@stanhu](https://github.com/stanhu). +* [#2103](https://github.com/ruby-grape/grape/pull/2103): Ensure complete declared params structure is present - [@tlconnor](https://github.com/tlconnor). +* [#2099](https://github.com/ruby-grape/grape/pull/2099): Added truffleruby to Travis-CI - [@gogainda](https://github.com/gogainda). * [#2089](https://github.com/ruby-grape/grape/pull/2089): Specify order of mounting Grape with Rack::Cascade in README - [@jonmchan](https://github.com/jonmchan). -* [#2083](https://github.com/ruby-grape/grape/pull/2083): Set `Cache-Control` header only for streamed responses - [@stanhu](https://github.com/stanhu). +* [#2088](https://github.com/ruby-grape/grape/pull/2088): Set `Cache-Control` header only for streamed responses - [@stanhu](https://github.com/stanhu). * [#2092](https://github.com/ruby-grape/grape/pull/2092): Correct an example params in Include Missing doc - [@huyvohcmc](https://github.com/huyvohcmc). * [#2091](https://github.com/ruby-grape/grape/pull/2091): Fix ruby 2.7 keyword deprecations - [@dim](https://github.com/dim). +* [#2097](https://github.com/ruby-grape/grape/pull/2097): Skip to set default value unless `meets_dependency?` - [@wanabe](https://github.com/wanabe). +* [#2096](https://github.com/ruby-grape/grape/pull/2096): Fix redundant dependency check - [@braktar](https://github.com/braktar). +* [#2096](https://github.com/ruby-grape/grape/pull/2098): Fix nested coercion - [@braktar](https://github.com/braktar). +* [#2102](https://github.com/ruby-grape/grape/pull/2102): Fix retaining setup blocks when remounting APIs - [@jylamont](https://github.com/jylamont). ### 1.4.0 (2020/07/10) @@ -607,7 +920,7 @@ * [#492](https://github.com/ruby-grape/grape/pull/492): Don't allow to have nil value when a param is required and has a list of allowed values - [@Antti](https://github.com/Antti). * [#495](https://github.com/ruby-grape/grape/pull/495): Fixed `ParamsScope#params` for parameters nested inside arrays - [@asross](https://github.com/asross). * [#498](https://github.com/ruby-grape/grape/pull/498): Dry'ed up options and headers logic, allow headers to be passed to OPTIONS requests - [@karlfreeman](https://github.com/karlfreeman). -* [#500](https://github.com/ruby-grape/grape/pull/500): Skip entity auto-detection when explicitely passed - [@yaneq](https://github.com/yaneq). +* [#500](https://github.com/ruby-grape/grape/pull/500): Skip entity auto-detection when explicitly passed - [@yaneq](https://github.com/yaneq). * [#503](https://github.com/ruby-grape/grape/pull/503): Calling declared(params) from child namespace fails to include parent namespace defined params - [@myitcv](https://github.com/myitcv). * [#512](https://github.com/ruby-grape/grape/pull/512): Don't create `Grape::Request` multiple times - [@dblock](https://github.com/dblock). * [#538](https://github.com/ruby-grape/grape/pull/538): Fixed default values for grouped params - [@dm1try](https://github.com/dm1try). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e27368ca6..18500e912 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ Contributing to Grape ===================== -Grape is work of [hundreds of contributors](https://github.com/ruby-grape/grape/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/ruby-grape/grape/pulls), [propose features and discuss issues](https://github.com/ruby-grape/grape/issues). When in doubt, ask a question in the [Grape Google Group](http://groups.google.com/group/ruby-grape). +Grape is work of [hundreds of contributors](https://github.com/ruby-grape/grape/graphs/contributors). You're encouraged to submit [pull requests](https://github.com/ruby-grape/grape/pulls), [propose features and discuss issues](https://github.com/ruby-grape/grape/issues). #### Fork the Project @@ -23,6 +23,34 @@ git pull upstream master git checkout -b my-feature-branch ``` +### Docker + +If you're familiar with [Docker](https://www.docker.com/), you can run everything through the following command: + +``` +docker-compose run --rm --build grape +``` + +About the execution process: + - displays Ruby, Rubygems, Bundle and Gemfile version when starting: + ``` + ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-linux-musl] + rubygems 3.4.12 + Bundler version 2.4.1 (2022-12-24 commit f3175f033c) + Running default Gemfile + ``` + - keeps the gems to the latest possible version + - executes under `bundle exec` + +Here are some examples: + +- running all specs `docker-compose run --rm --build grape rspec` +- running rspec on a specific file `docker-compose run --rm --build grape rspec spec/:file_path` +- running task `docker-compose run --rm --build grape rake ` +- running rubocop `docker-compose run --rm --build grape rubocop` +- running all specs on a specific ruby version (e.g 2.7.7) `RUBY_VERSION=2.7.7 docker-compose run --rm --build grape rspec` +- running specs on a specific gemfile (e.g rails_7_0.gemfile) `docker-compose run -e GEMFILE=rails_7_0 --rm --build grape rspec` + #### Bundle Install and Test Ensure that you can build the project and run tests. @@ -35,6 +63,7 @@ bundle exec rake Run tests against all supported versions of Rails. ``` +gem install appraisal appraisal install appraisal rake spec ``` @@ -57,6 +86,8 @@ Make sure that `bundle exec rake` completes without errors. Document any external behavior in the [README](README.md). +You should also document code as necessary, using current code as examples. This project uses [YARD](https://yardoc.org/). You can run and preview the docs locally by [installing `yard`](https://yardoc.org/), running `yard server --reload` and view the docs at http://localhost:8808. + #### Update Changelog Add a line to [CHANGELOG](CHANGELOG.md) under *Next Release*. Make it look like every other line, including your name and link to your Github account. @@ -114,11 +145,11 @@ git push origin my-feature-branch -f #### Check on Your Pull Request -Go back to your pull request after a few minutes and see whether it passed muster with Travis-CI. Everything should look green, otherwise fix issues and amend your commit as described above. +Go back to your pull request after a few minutes and see whether it passed muster with CI. Everything should look green, otherwise fix issues and amend your commit as described above. #### Be Patient -It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang on there! +It's likely that your change will not be merged and that the nitpicky maintainers will ask you to do more, or fix seemingly benign problems. Hang in there! #### Thank You diff --git a/Dangerfile b/Dangerfile index 2f1200640..527dbb8b7 100644 --- a/Dangerfile +++ b/Dangerfile @@ -1,4 +1,4 @@ # frozen_string_literal: true danger.import_dangerfile(gem: 'ruby-grape-danger') -toc.check +toc.check! diff --git a/Gemfile b/Gemfile index 48a26eb17..ae3753cd9 100644 --- a/Gemfile +++ b/Gemfile @@ -2,35 +2,37 @@ # when changing this file, run appraisal install ; rubocop -a gemfiles/*.gemfile -source 'https://rubygems.org' +source('https://rubygems.org') gemspec group :development, :test do + gem 'builder', require: false gem 'bundler' - gem 'hashie' gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false + gem 'rubocop', '1.71.2', require: false + gem 'rubocop-performance', '1.23.1', require: false + gem 'rubocop-rspec', '3.4.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 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false + gem 'rack-contrib', require: false + gem 'rack-test', '~> 2.1' + gem 'rspec', '~> 3.13' + gem 'ruby-grape-danger', '~> 0.2', require: false + gem 'simplecov', '~> 0.21', require: false + gem 'simplecov-lcov', '~> 0.8', require: false + gem 'test-prof', require: false +end + +platforms :jruby do + gem 'racc' end diff --git a/README.md b/README.md index c3a5ce73b..7fe411835 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ ![grape logo](grape.png) [![Gem Version](https://badge.fury.io/rb/grape.svg)](http://badge.fury.io/rb/grape) -[![Build Status](https://travis-ci.org/ruby-grape/grape.svg?branch=master)](https://travis-ci.org/ruby-grape/grape) -[![Code Climate](https://codeclimate.com/github/ruby-grape/grape.svg)](https://codeclimate.com/github/ruby-grape/grape) +[![test](https://github.com/ruby-grape/grape/actions/workflows/test.yml/badge.svg)](https://github.com/ruby-grape/grape/actions/workflows/test.yml) [![Coverage Status](https://coveralls.io/repos/github/ruby-grape/grape/badge.svg?branch=master)](https://coveralls.io/github/ruby-grape/grape?branch=master) -[![Inline docs](https://inch-ci.org/github/ruby-grape/grape.svg)](https://inch-ci.org/github/ruby-grape/grape) -[![Join the chat at https://gitter.im/ruby-grape/grape](https://badges.gitter.im/ruby-grape/grape.svg)](https://gitter.im/ruby-grape/grape?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ## Table of Contents @@ -15,22 +12,22 @@ - [Grape for Enterprise](#grape-for-enterprise) - [Installation](#installation) - [Basic Usage](#basic-usage) +- [Rails 7.1](#rails-71) - [Mounting](#mounting) - [All](#all) - [Rack](#rack) - - [ActiveRecord without Rails](#activerecord-without-rails) - [Alongside Sinatra (or other frameworks)](#alongside-sinatra-or-other-frameworks) - [Rails](#rails) - - [Rails < 5.2](#rails--52) - - [Rails 6.0](#rails-60) + - [Zeitwerk](#zeitwerk) - [Modules](#modules) - [Remounting](#remounting) - [Mount Configuration](#mount-configuration) - [Versioning](#versioning) - - [Path](#path) - - [Header](#header) - - [Accept-Version Header](#accept-version-header) - - [Param](#param) + - [Strategies](#strategies) + - [Path](#path) + - [Header](#header) + - [Accept-Version Header](#accept-version-header) + - [Param](#param) - [Describing Methods](#describing-methods) - [Configuration](#configuration) - [Parameters](#parameters) @@ -38,6 +35,8 @@ - [Declared](#declared) - [Include Parent Namespaces](#include-parent-namespaces) - [Include Missing](#include-missing) + - [Evaluate Given](#evaluate-given) + - [Parameter Precedence](#parameter-precedence) - [Parameter Validation and Coercion](#parameter-validation-and-coercion) - [Supported Parameter Types](#supported-parameter-types) - [Integer/Fixnum and Coercions](#integerfixnum-and-coercions) @@ -54,6 +53,7 @@ - [values](#values) - [except_values](#except_values) - [same_as](#same_as) + - [length](#length) - [regexp](#regexp) - [mutually_exclusive](#mutually_exclusive) - [exactly_one_of](#exactly_one_of) @@ -67,6 +67,7 @@ - [Custom Validation messages](#custom-validation-messages) - [presence, allow_blank, values, regexp](#presence-allow_blank-values-regexp) - [same_as](#same_as-1) + - [length](#length-1) - [all_or_none_of](#all_or_none_of-1) - [mutually_exclusive](#mutually_exclusive-1) - [exactly_one_of](#exactly_one_of-1) @@ -76,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) @@ -96,7 +98,6 @@ - [Rescuing exceptions inside namespaces](#rescuing-exceptions-inside-namespaces) - [Unrescuable Exceptions](#unrescuable-exceptions) - [Exceptions that should be rescued explicitly](#exceptions-that-should-be-rescued-explicitly) - - [Rails 3.x](#rails-3x) - [Logging](#logging) - [API Formats](#api-formats) - [JSONP](#jsonp) @@ -111,12 +112,13 @@ - [Active Model Serializers](#active-model-serializers) - [Sending Raw or No Data](#sending-raw-or-no-data) - [Authentication](#authentication) - - [Basic and Digest Auth](#basic-and-digest-auth) + - [Basic Auth](#basic-auth) - [Register custom middleware for authentication](#register-custom-middleware-for-authentication) - [Describing and Inspecting an API](#describing-and-inspecting-an-api) - [Current Route and Endpoint](#current-route-and-endpoint) - [Before, After and Finally](#before-after-and-finally) - [Anchoring](#anchoring) +- [Instance Variables](#instance-variables) - [Using Custom Middleware](#using-custom-middleware) - [Grape Middleware](#grape-middleware) - [Rails Middleware](#rails-middleware) @@ -148,23 +150,18 @@ ## What is Grape? -Grape is a REST-like API framework for Ruby. It's designed to run on Rack -or complement existing web application frameworks such as Rails and Sinatra by -providing a simple DSL to easily develop RESTful APIs. It has built-in support -for common conventions, including multiple formats, subdomain/prefix restriction, -content negotiation, versioning and much more. +Grape is a REST-like API framework for Ruby. It's designed to run on Rack or complement existing web application frameworks such as Rails and Sinatra by providing a simple DSL to easily develop RESTful APIs. It has built-in support for common conventions, including multiple formats, subdomain/prefix restriction, content negotiation, versioning and much more. ## Stable Release -You're reading the documentation for the next release of Grape, which should be **1.4.1**. -Please read [UPGRADING](UPGRADING.md) when upgrading from a previous version. -The current stable release is [1.4.0](https://github.com/ruby-grape/grape/blob/v1.4.0/README.md). +You're reading the documentation for the next release of Grape, which should be 2.4.0. +The current stable release is [2.3.0](https://github.com/ruby-grape/grape/blob/v2.3.0/README.md). ## Project Resources * [Grape Website](http://www.ruby-grape.org) * [Documentation](http://www.rubydoc.info/gems/grape) -* Need help? Try [Grape Google Group](http://groups.google.com/group/ruby-grape) or [Gitter](https://gitter.im/ruby-grape/grape) +* Need help? [Open an Issue](https://github.com/ruby-grape/grape/issues) * [Follow us on Twitter](https://twitter.com/grapeframework) ## Grape for Enterprise @@ -173,27 +170,18 @@ Available as part of the Tidelift Subscription. The maintainers of Grape are working with Tidelift to deliver commercial support and maintenance. Save time, reduce risk, and improve code health, while paying the maintainers of Grape. Click [here](https://tidelift.com/subscription/request-a-demo?utm_source=rubygems-grape&utm_medium=referral&utm_campaign=enterprise) for more details. -In 2020, we plan to use the money towards gathering Grape contributors for dinner in New York City. - ## Installation -Ruby 2.4 or newer is required. - -Grape is available as a gem, to install it just install the gem: - - gem install grape +Ruby 2.7 or newer is required. -If you're using Bundler, add the gem to Gemfile. +Grape is available as a gem, to install it run: - gem 'grape' - -Run `bundle install`. + bundle add grape ## Basic Usage Grape APIs are Rack applications that are created by subclassing `Grape::API`. -Below is a simple example showing some of the more common features of Grape in -the context of recreating parts of the Twitter API. +Below is a simple example showing some of the more common features of Grape in the context of recreating parts of the Twitter API. ```ruby module Twitter @@ -272,12 +260,16 @@ module Twitter end ``` +## Rails 7.1 + +Grape's [deprecator](https://api.rubyonrails.org/v7.1.0/classes/ActiveSupport/Deprecation.html) will be added to your application's deprecators [automatically](lib/grape/railtie.rb) as `:grape`, so that your application's configuration can be applied to it. + ## Mounting ### All -By default Grape will compile the routes on the first route, it is possible to pre-load routes using the `compile!` method. +By default Grape will compile the routes on the first route, but it is possible to pre-load routes using the `compile!` method. ```ruby Twitter::API.compile! @@ -287,8 +279,7 @@ This can be added to your `config.ru` (if using rackup), `application.rb` (if us ### Rack -The above sample creates a Rack application that can be run from a rackup `config.ru` file -with `rackup`: +The above sample creates a Rack application that can be run from a rackup `config.ru` file with `rackup`: ```ruby run Twitter::API @@ -312,24 +303,9 @@ And would respond to the following routes: Grape will also automatically respond to HEAD and OPTIONS for all GET, and just OPTIONS for all other routes. -### ActiveRecord without Rails - -If you want to use ActiveRecord within Grape, you will need to make sure that ActiveRecord's connection pool -is handled correctly. - -The easiest way to achieve that is by using ActiveRecord's `ConnectionManagement` middleware in your -`config.ru` before mounting Grape, e.g.: - -```ruby -use ActiveRecord::ConnectionAdapters::ConnectionManagement - -run Twitter::API -``` - ### Alongside Sinatra (or other frameworks) -If you wish to mount Grape alongside another Rack framework such as Sinatra, you can do so easily using -`Rack::Cascade`: +If you wish to mount Grape alongside another Rack framework such as Sinatra, you can do so easily using `Rack::Cascade`: ```ruby # Example config.ru @@ -353,7 +329,7 @@ use Rack::Session::Cookie run Rack::Cascade.new [Web, API] ``` -Note that order of loading apps using `Rack::Cascade` matters. The grape application must be last if you want to raise custom 404 errors from grape (such as `error!('Not Found',404)`). If the grape application is not last and returns 404 or 405 response, [cascade utilizes that as a signal to try the next app](https://www.rubydoc.info/gems/rack/Rack/Cascade). This may lead to undesirable behavior showing the [wrong 404 page from the wrong app](https://github.com/ruby-grape/grape/issues/1515). +Note that order of loading apps using `Rack::Cascade` matters. The grape application must be last if you want to raise custom 404 errors from grape (such as `error!('Not Found',404)`). If the grape application is not last and returns 404 or 405 response, [cascade utilizes that as a signal to try the next app](https://www.rubydoc.info/gems/rack/Rack/Cascade). This may lead to undesirable behavior showing the [wrong 404 page from the wrong app](https://github.com/ruby-grape/grape/issues/1515). ### Rails @@ -365,21 +341,8 @@ Modify `config/routes`: ```ruby mount Twitter::API => '/' ``` - -#### Rails < 5.2 - -Modify `application.rb`: - -```ruby -config.paths.add File.join('app', 'api'), glob: File.join('**', '*.rb') -config.autoload_paths += Dir[Rails.root.join('app', 'api', '*')] -``` - -See [below](#reloading-api-changes-in-development) for additional code that enables reloading of API changes in development. - -#### Rails 6.0 - -For Rails versions greater than 6.0.0.beta2, `Zeitwerk` autoloader is the default for CRuby. By default `Zeitwerk` inflects `api` as `Api` instead of `API`. To make our example work, you need to uncomment the lines at the bottom of `config/initializers/inflections.rb`, and add `API` as an acronym: +#### Zeitwerk +Rails's default autoloader is `Zeitwerk`. By default, it inflects `api` as `Api` instead of `API`. To make our example work, you need to uncomment the lines at the bottom of `config/initializers/inflections.rb`, and add `API` as an acronym: ```ruby ActiveSupport::Inflector.inflections(:en) do |inflect| @@ -389,8 +352,7 @@ end ### Modules -You can mount multiple API implementations inside another one. These don't have to be -different versions, but may be components of the same API. +You can mount multiple API implementations inside another one. These don't have to be different versions, but may be components of the same API. ```ruby class Twitter::API < Grape::API @@ -407,7 +369,7 @@ class Twitter::API < Grape::API end ``` -Keep in mind such declarations as `before/after/rescue_from` must be placed before `mount` in a case where they should be inherited. +Declarations as `before/after/rescue_from` can be placed before or after `mount`. In any case they will be inherited. ```ruby class Twitter::API < Grape::API @@ -415,8 +377,20 @@ class Twitter::API < Grape::API header 'X-Base-Header', 'will be defined for all APIs that are mounted below' end + rescue_from :all do + error!({ "error" => "Internal Server Error" }, 500) + end + mount Twitter::Users mount Twitter::Search + + after do + clean_cache! + end + + rescue_from ZeroDivisionError do + error!({ "error" => "Not found" }, 404) + end end ``` @@ -522,35 +496,94 @@ end ```ruby class BasicAPI < Grape::API desc 'Statuses index' do - params: mounted { configuration[:entity] || API::Entities::Status }.documentation + params: (configuration[:entity] || API::Entities::Status).documentation end params do - requires :all, using: mounted { configuration[:entity] || API::Entities::Status }.documentation + requires :all, using: (configuration[:entity] || API::Entities::Status).documentation end get '/statuses' do statuses = Status.all type = current_user.admin? ? :full : :default - present statuses, with: mounted { configuration[:entity] || API::Entities::Status }, type: type + present statuses, with: (configuration[:entity] || API::Entities::Status), type: type end end class V1 < Grape::API version 'v1' - mount BasicAPI, with: { entity: mounted { configuration[:entity] || API::Enitities::Status } } + mount BasicAPI, with: { entity: mounted { configuration[:entity] || API::Entities::Status } } end class V2 < Grape::API version 'v2' - mount BasicAPI, with: { entity: mounted { configuration[:entity] || API::Enitities::V2::Status } } + mount BasicAPI, with: { entity: mounted { configuration[:entity] || API::Entities::V2::Status } } end ``` ## Versioning -There are four strategies in which clients can reach your API's endpoints: `:path`, -`:header`, `:accept_version_header` and `:param`. The default strategy is `:path`. +You have the option to provide various versions of your API by establishing a separate `Grape::API` class for each offered version and then integrating them into a primary `Grape::API` class. Ensure that newer versions are mounted before older ones. The default approach to versioning directs the request to the subsequent Rack middleware if a specific version is not found. -### Path +```ruby +require 'v1' +require 'v2' +require 'v3' +class App < Grape::API + mount V3 + mount V2 + mount V1 +end +``` + +To maintain the same endpoints from earlier API versions without rewriting them, you can indicate multiple versions within the previous API versions. + +```ruby +class V1 < Grape::API + version 'v1', 'v2', 'v3' + + get '/foo' do + # your code for GET /foo + end + + get '/other' do + # your code for GET /other + end +end + +class V2 < Grape::API + version 'v2', 'v3' + + get '/var' do + # your code for GET /var + end +end + +class V3 < Grape::API + version 'v3' + + get '/foo' do + # your new code for GET /foo + end +end +``` + +Using the example provided, the subsequent endpoints will be accessible across various versions: + +```shell +GET /v1/foo +GET /v1/other +GET /v2/foo # => Same behavior as v1 +GET /v2/other # => Same behavior as v1 +GET /v2/var # => New endpoint not available in v1 +GET /v3/foo # => Different behavior to v1 and v2 +GET /v3/other # => Same behavior as v1 and v2 +GET /v3/var # => Same behavior as v2 +``` + +There are four strategies in which clients can reach your API's endpoints: `:path`, `:header`, `:accept_version_header` and `:param`. The default strategy is `:path`. + +### Strategies + +#### Path ```ruby version 'v1', using: :path @@ -560,7 +593,7 @@ Using this versioning strategy, clients should pass the desired version in the U curl http://localhost:9292/v1/statuses/public_timeline -### Header +#### Header ```ruby version 'v1', using: :header, vendor: 'twitter' @@ -578,16 +611,15 @@ Using this versioning strategy, clients should pass the desired version in the H curl -H Accept:application/vnd.twitter-v1+json http://localhost:9292/statuses/public_timeline -By default, the first matching version is used when no `Accept` header is -supplied. This behavior is similar to routing in Rails. To circumvent this default behavior, -one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error -is returned when no correct `Accept` header is supplied. +By default, the first matching version is used when no `Accept` header is supplied. This behavior is similar to routing in Rails. To circumvent this default behavior, one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error is returned when no correct `Accept` header is supplied. + +When an invalid `Accept` header is supplied, a `406 Not Acceptable` error is returned if the `:cascade` option is set to `false`. Otherwise a `404 Not Found` error is returned by Rack if no other route matches. + +Grape will evaluate the relative quality preference included in Accept headers and default to a quality of 1.0 when omitted. In the following example a Grape API that supports XML and JSON in that order will return JSON: -When an invalid `Accept` header is supplied, a `406 Not Acceptable` error is returned if the `:cascade` -option is set to `false`. Otherwise a `404 Not Found` error is returned by Rack if no other route -matches. + curl -H "Accept: text/xml;q=0.8, application/json;q=0.9" localhost:1234/resource -### Accept-Version Header +#### Accept-Version Header ```ruby version 'v1', using: :accept_version_header @@ -597,20 +629,15 @@ Using this versioning strategy, clients should pass the desired version in the H curl -H "Accept-Version:v1" http://localhost:9292/statuses/public_timeline -By default, the first matching version is used when no `Accept-Version` header is -supplied. This behavior is similar to routing in Rails. To circumvent this default behavior, -one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error -is returned when no correct `Accept` header is supplied and the `:cascade` option is set to `false`. -Otherwise a `404 Not Found` error is returned by Rack if no other route matches. +By default, the first matching version is used when no `Accept-Version` header is supplied. This behavior is similar to routing in Rails. To circumvent this default behavior, one could use the `:strict` option. When this option is set to `true`, a `406 Not Acceptable` error is returned when no correct `Accept` header is supplied and the `:cascade` option is set to `false`. Otherwise a `404 Not Found` error is returned by Rack if no other route matches. -### Param +#### Param ```ruby version 'v1', using: :param ``` -Using this versioning strategy, clients should pass the desired version as a request parameter, -either in the URL query string or in the request body. +Using this versioning strategy, clients should pass the desired version as a request parameter, either in the URL query string or in the request body. curl http://localhost:9292/statuses/public_timeline?apiver=v1 @@ -636,6 +663,7 @@ desc 'Returns your public timeline.' do params API::Entities::Status.documentation success API::Entities::Entity failure [[401, 'Unauthorized', 'Entities::Error']] + default { code: 500, message: 'InvalidRequest', model: Entities::Error } named 'My named route' headers XAuthToken: { description: 'Validates your identity', @@ -660,8 +688,9 @@ end * `detail`: A more enhanced description * `params`: Define parameters directly from an `Entity` -* `success`: (former entity) The `Entity` to be used to present by default this route -* `failure`: (former http_codes) A definition of the used failure HTTP Codes and Entities +* `success`: (former entity) The `Entity` to be used to present the success response for this route. +* `failure`: (former http_codes) A definition of the used failure HTTP Codes and Entities. +* `default`: The definition and `Entity` used to present the default response for this route. * `named`: A helper to give a route a name and find it with this name in the documentation Hash * `headers`: A definition of the used Headers * Other options can be found in [grape-swagger][grape-swagger] @@ -687,10 +716,13 @@ For example, for the `param_builder`, the following code could run in an initial ```ruby Grape.configure do |config| - config.param_builder = Grape::Extensions::Hashie::Mash::ParamBuilder + config.param_builder = :hashie_mash end ``` +Available parameter builders are `:hash`, `:hash_with_indifferent_access`, and `:hashie_mash`. +See [params_builder](lib/grape/params_builder). + You can also configure a single API: ```ruby @@ -699,13 +731,11 @@ API.configure do |config| end ``` -This will be available inside the API with `configuration`, as if it were -[mount configuration](#mount-configuration). +This will be available inside the API with `configuration`, as if it were [mount configuration](#mount-configuration). ## Parameters -Request parameters are available through the `params` hash object. This includes `GET`, `POST` -and `PUT` parameters, along with any named parameters you specify in your route strings. +Request parameters are available through the `params` hash object. This includes `GET`, `POST` and `PUT` parameters, along with any named parameters you specify in your route strings. ```ruby get :public_timeline do @@ -713,8 +743,7 @@ get :public_timeline do end ``` -Parameters are automatically populated from the request body on `POST` and `PUT` for form input, JSON and -XML content-types. +Parameters are automatically populated from the request body on `POST` and `PUT` for form input, JSON and XML content-types. The request: @@ -760,7 +789,7 @@ By default parameters are available as `ActiveSupport::HashWithIndifferentAccess ```ruby class API < Grape::API - include Grape::Extensions::Hashie::Mash::ParamBuilder + build_with :hashie_mash params do optional :color, type: String @@ -774,20 +803,25 @@ The class can also be overridden on individual parameter blocks using `build_wit ```ruby params do - build_with Grape::Extensions::Hash::ParamBuilder + build_with :hash optional :color, type: String end ``` -Or globally with the [Configuration](#configuration) `Grape.configure.param_builder`. - In the example above, `params["color"]` will return `nil` since `params` is a plain `Hash`. -Available parameter builders are `Grape::Extensions::Hash::ParamBuilder`, `Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder` and `Grape::Extensions::Hashie::Mash::ParamBuilder`. +Available parameter builders are `:hash`, `:hash_with_indifferent_access`, and `:hashie_mash`. +See [params_builder](lib/grape/params_builder). ### Declared -Grape allows you to access only the parameters that have been declared by your `params` block. It filters out the params that have been passed, but are not allowed. Consider the following API endpoint: +Grape allows you to access only the parameters that have been declared by your `params` block. It will: + + * Filter out the params that have been passed, but are not allowed. + * Include any optional params that are declared but not passed. + * Perform any parameter renaming on the resulting hash. + +Consider the following API endpoint: ````ruby format :json @@ -820,9 +854,9 @@ Once we add parameters requirements, grape will start returning only the declare format :json params do - requires :user, type: Hash do - requires :first_name, type: String - requires :last_name, type: String + optional :user, type: Hash do + optional :first_name, type: String + optional :last_name, type: String end end @@ -850,6 +884,44 @@ curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d } ```` +Missing params that are declared as type `Hash` or `Array` will be included. + +````ruby +format :json + +params do + optional :user, type: Hash do + optional :first_name, type: String + optional :last_name, type: String + end + optional :widgets, type: Array +end + +post 'users/signup' do + { 'declared_params' => declared(params) } +end +```` + +**Request** + +````bash +curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d '{}' +```` + +**Response** + +````json +{ + "declared_params": { + "user": { + "first_name": null, + "last_name": null + }, + "widgets": [] + } +} +```` + The returned hash is an `ActiveSupport::HashWithIndifferentAccess`. The `#declared` method is not available to `before` filters, as those are evaluated prior to parameter coercion. @@ -942,8 +1014,10 @@ curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d ````json { "declared_params": { - "first_name": "first name", - "last_name": null + "user": { + "first_name": "first name", + "last_name": null + } } } ```` @@ -1007,8 +1081,7 @@ curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d } ```` -Note that an attribute with a `nil` value is not considered *missing* and will also be returned -when `include_missing` is set to `false`: +Note that an attribute with a `nil` value is not considered *missing* and will also be returned when `include_missing` is set to `false`: **Request** @@ -1030,6 +1103,131 @@ curl -X POST -H "Content-Type: application/json" localhost:9292/users/signup -d } ```` +### Evaluate Given + +By default `declared(params)` will not evaluate `given` and return all parameters. Use `evaluate_given` to evaluate all `given` blocks and return only parameters that satisfy `given` conditions. Consider the following API: + +````ruby +format :json + +params do + optional :child_id, type: Integer + given :child_id do + requires :father_id, type: Integer + end +end + +post 'child' do + { 'declared_params' => declared(params, evaluate_given: true) } +end +```` + +**Request** + +````bash +curl -X POST -H "Content-Type: application/json" localhost:9292/child -d '{"father_id": 1}' +```` + +**Response with evaluate_given:false** + +````json +{ + "declared_params": { + "child_id": null, + "father_id": 1 + } +} +```` + +**Response with evaluate_given:true** + +````json +{ + "declared_params": { + "child_id": null + } +} +```` + +It also works on nested hashes: + +````ruby +format :json + +params do + requires :child, type: Hash do + optional :child_id, type: Integer + given :child_id do + requires :father_id, type: Integer + end + end +end + +post 'child' do + { 'declared_params' => declared(params, evaluate_given: true) } +end +```` + +**Request** + +````bash +curl -X POST -H "Content-Type: application/json" localhost:9292/child -d '{"child": {"father_id": 1}}' +```` + +**Response with evaluate_given:false** + +````json +{ + "declared_params": { + "child": { + "child_id": null, + "father_id": 1 + } + } +} +```` + +**Response with evaluate_given:true** + +````json +{ + "declared_params": { + "child": { + "child_id": null + } + } +} +```` + +### Parameter Precedence + +Using `route_param` takes higher precedence over a regular parameter defined with same name: + +```ruby +params do + requires :foo, type: String +end +route_param :foo do + get do + { value: params[:foo] } + end +end +``` + +**Request** + +```bash +curl -X POST -H "Content-Type: application/json" localhost:9292/bar -d '{"foo": "baz"}' +``` + +**Response** + +```json +{ + "value": "bar" +} +``` + ## Parameter Validation and Coercion You can define validations and coercion options for your parameters using a `params` block. @@ -1051,8 +1249,7 @@ put ':id' do end ``` -When a type is specified an implicit validation is done after the coercion to ensure -the output type is the one declared. +When a type is specified an implicit validation is done after the coercion to ensure the output type is the one declared. Optional parameters can have a default value. @@ -1064,9 +1261,7 @@ params do end ``` -Default values are eagerly evaluated. Above `:non_random_number` will evaluate to the same -number for each call to the endpoint of this `params` block. To have the default evaluate -lazily with each request use a lambda, like `:random_number` above. +Default values are eagerly evaluated. Above `:non_random_number` will evaluate to the same number for each call to the endpoint of this `params` block. To have the default evaluate lazily with each request use a lambda, like `:random_number` above. Note that default values will be passed through to any validation options specified. The following example will always fail if `:color` is not explicitly provided. @@ -1085,6 +1280,15 @@ params do end ``` +You can use the value of one parameter as the default value of some other parameter. In this case, if the `primary_color` parameter is not provided, it will have the same value as the `color` one. If both of them not provided, both of them will have `blue` value. + +```ruby +params do + optional :color, type: String, default: 'blue' + optional :primary_color, type: String, default: -> (params) { params[:color] } +end +``` + ### Supported Parameter Types The following are all valid types, supported out of the box by Grape: @@ -1126,11 +1330,7 @@ get '/int' integers: { int: '45' } ### Custom Types and Coercions -Aside from the default set of supported types listed above, any class can be -used as a type as long as an explicit coercion method is supplied. If the type -implements a class-level `parse` method, Grape will use it automatically. -This method must take one string argument and return an instance of the correct -type, or raise an exception to indicate the value was invalid. E.g., +Aside from the default set of supported types listed above, any class can be used as a type as long as an explicit coercion method is supplied. If the type implements a class-level `parse` method, Grape will use it automatically. This method must take one string argument and return an instance of the correct type, or return an instance of `Grape::Types::InvalidValue` which optionally accepts a message to be returned in the response. ```ruby class Color @@ -1140,8 +1340,9 @@ class Color end def self.parse(value) - fail 'Invalid color' unless %w(blue red green).include?(value) - new(value) + return new(value) if %w[blue red green].include?(value) + + Grape::Types::InvalidValue.new('Unsupported color') end end @@ -1157,10 +1358,7 @@ get '/stuff' do end ``` -Alternatively, a custom coercion method may be supplied for any type of parameter -using `coerce_with`. Any class or object may be given that implements a `parse` or -`call` method, in that order of precedence. The method must accept a single string -parameter, and the return value must match the given `type`. +Alternatively, a custom coercion method may be supplied for any type of parameter using `coerce_with`. Any class or object may be given that implements a `parse` or `call` method, in that order of precedence. The method must accept a single string parameter, and the return value must match the given `type`. ```ruby params do @@ -1173,6 +1371,7 @@ params do end end ``` +Note that, a `nil` value will call the custom coercion method, while a missing parameter will not. Example of use of `coerce_with` with a lambda (a class with a `parse` method could also have been used) It will parse a string and return an Array of Integers, matching the `Array[Integer]` `type`. @@ -1183,9 +1382,7 @@ params do end ``` -Grape will assert that coerced values match the given `type`, and will reject the request -if they do not. To override this behaviour, custom types may implement a `parsed?` method -that should accept a single argument and return `true` if the value passes type validation. +Grape will assert that coerced values match the given `type`, and will reject the request if they do not. To override this behaviour, custom types may implement a `parsed?` method that should accept a single argument and return `true` if the value passes type validation. ```ruby class SecureUri @@ -1220,9 +1417,7 @@ end ### First-Class `JSON` Types -Grape supports complex parameters given as JSON-formatted strings using the special `type: JSON` -declaration. JSON objects and arrays of objects are accepted equally, with nested validation -rules applied to all objects in either case: +Grape supports complex parameters given as JSON-formatted strings using the special `type: JSON` declaration. JSON objects and arrays of objects are accepted equally, with nested validation rules applied to all objects in either case: ```ruby params do @@ -1241,8 +1436,7 @@ client.get('/', json: '{"int":4}') # => HTTP 400 client.get('/', json: '[{"int":4}]') # => HTTP 400 ``` -Additionally `type: Array[JSON]` may be used, which explicitly marks the parameter as an array -of objects. If a single object is supplied it will be wrapped. +Additionally `type: Array[JSON]` may be used, which explicitly marks the parameter as an array of objects. If a single object is supplied it will be wrapped. ```ruby params do @@ -1254,8 +1448,7 @@ get '/' do params[:json].each { |obj| ... } # always works end ``` -For stricter control over the type of JSON structure which may be supplied, -use `type: Array, coerce_with: JSON` or `type: Hash, coerce_with: JSON`. +For stricter control over the type of JSON structure which may be supplied, use `type: Array, coerce_with: JSON` or `type: Hash, coerce_with: JSON`. ### Multiple Allowed Types @@ -1274,8 +1467,7 @@ client.get('/', status_code: 300) # => 300 client.get('/', status_code: %w(404 NOT FOUND)) # => [404, "NOT", "FOUND"] ``` -As a special case, variant-member-type collections may also be declared, by -passing a `Set` or `Array` with more than one member to `type`: +As a special case, variant-member-type collections may also be declared, by passing a `Set` or `Array` with more than one member to `type`: ```ruby params do @@ -1291,11 +1483,8 @@ client.get('/', status_codes: %w(1 two)) # => [1, "two"] ### Validation of Nested Parameters Parameters can be nested using `group` or by calling `requires` or `optional` with a block. -In the [above example](#parameter-validation-and-coercion), this means `params[:media][:url]` is required along with `params[:id]`, -and `params[:audio][:format]` is required only if `params[:audio]` is present. -With a block, `group`, `requires` and `optional` accept an additional option `type` which can -be either `Array` or `Hash`, and defaults to `Array`. Depending on the value, the nested -parameters will be treated either as values of a hash or as values of hashes in an array. +In the [above example](#parameter-validation-and-coercion), this means `params[:media][:url]` is required along with `params[:id]`, and `params[:audio][:format]` is required only if `params[:audio]` is present. +With a block, `group`, `requires` and `optional` accept an additional option `type` which can be either `Array` or `Hash`, and defaults to `Array`. Depending on the value, the nested parameters will be treated either as values of a hash or as values of hashes in an array. ```ruby params do @@ -1313,9 +1502,7 @@ end ### Dependent Parameters -Suppose some of your parameters are only relevant if another parameter is given; -Grape allows you to express this relationship through the `given` method in your -parameters block, like so: +Suppose some of your parameters are only relevant if another parameter is given; Grape allows you to express this relationship through the `given` method in your parameters block, like so: ```ruby params do @@ -1354,31 +1541,45 @@ Note: param in `given` should be the renamed one. In the example, it should be ` ### Group Options -Parameters options can be grouped. It can be useful if you want to extract -common validation or types for several parameters. The example below presents a -typical case when parameters share common options. +Parameters options can be grouped. It can be useful if you want to extract common validation or types for several parameters. +Within these groups, individual parameters can extend or selectively override the common settings, allowing you to maintain the defaults at the group level while still applying parameter-specific rules where necessary. + +The example below presents a typical case when parameters share common options. ```ruby params do - requires :first_name, type: String, regexp: /w+/, desc: 'First name' - requires :middle_name, type: String, regexp: /w+/, desc: 'Middle name' - requires :last_name, type: String, regexp: /w+/, desc: 'Last name' + requires :first_name, type: String, regexp: /w+/, desc: 'First name', documentation: { in: 'body' } + optional :middle_name, type: String, regexp: /w+/, desc: 'Middle name', documentation: { in: 'body', x: { nullable: true } } + requires :last_name, type: String, regexp: /w+/, desc: 'Last name', documentation: { in: 'body' } end ``` -Grape allows you to present the same logic through the `with` method in your -parameters block, like so: +Grape allows you to present the same logic through the `with` method in your parameters block, like so: ```ruby params do - with(type: String, regexp: /w+/) do + with(type: String, regexp: /w+/, documentation: { in: 'body' }) do requires :first_name, desc: 'First name' - requires :middle_name, desc: 'Middle name' + optional :middle_name, desc: 'Middle name', documentation: { x: { nullable: true } } requires :last_name, desc: 'Last name' end end ``` +You can organize settings into layers using nested `with' blocks. Each layer can use, add to, or change the settings of the layer above it. This helps to keep complex parameters organized and consistent, while still allowing for specific customizations to be made. + +```ruby +params do + with(documentation: { in: 'body' }) do # Applies documentation to all nested parameters + with(type: String, regexp: /\w+/) do # Applies type and validation to names + requires :first_name, desc: 'First name' + requires :last_name, desc: 'Last name' + end + optional :age, type: Integer, desc: 'Age', documentation: { x: { nullable: true } } # Specific settings for 'age' + end +end +``` + ### Renaming You can rename parameters using `as`, which can be useful when refactoring existing APIs: @@ -1395,19 +1596,15 @@ resource :users do end ``` -The value passed to `as` will be the key when calling `params` or `declared(params)`. +The value passed to `as` will be the key when calling `declared(params)`. ### Built-in Validators #### `allow_blank` -Parameters can be defined as `allow_blank`, ensuring that they contain a value. By default, `requires` -only validates that a parameter was sent in the request, regardless its value. With `allow_blank: false`, -empty values or whitespace only values are invalid. +Parameters can be defined as `allow_blank`, ensuring that they contain a value. By default, `requires` only validates that a parameter was sent in the request, regardless its value. With `allow_blank: false`, empty values or whitespace only values are invalid. -`allow_blank` can be combined with both `requires` and `optional`. If the parameter is required, it has to contain -a value. If it's optional, it's possible to not send it in the request, but if it's being sent, it has to have -some value, and not an empty string/only whitespaces. +`allow_blank` can be combined with both `requires` and `optional`. If the parameter is required, it has to contain a value. If it's optional, it's possible to not send it in the request, but if it's being sent, it has to have some value, and not an empty string/only whitespaces. ```ruby @@ -1439,6 +1636,15 @@ params do end ``` +Note endless ranges are also supported with ActiveSupport >= 6.0, but they require that the type be provided. + +```ruby +params do + requires :minimum, type: Integer, values: 10.. + optional :maximum, type: Integer, values: ..10 +end +``` + Note that *both* range endpoints have to be a `#kind_of?` your `:type` option (if you don't supply the `:type` option, it will be guessed to be equal to the class of the range's first endpoint). So the following is invalid: ```ruby @@ -1449,11 +1655,9 @@ end ``` The `:values` option can also be supplied with a `Proc`, evaluated lazily with each request. -If the Proc has arity zero (i.e. it takes no arguments) it is expected to return either a list -or a range which will then be used to validate the parameter. +If the Proc has arity zero (i.e. it takes no arguments) it is expected to return either a list or a range which will then be used to validate the parameter. -For example, given a status model you may want to restrict by hashtags that you have -previously defined in the `HashTag` model. +For example, given a status model you may want to restrict by hashtags that you have previously defined in the `HashTag` model. ```ruby params do @@ -1461,10 +1665,7 @@ params do end ``` -Alternatively, a Proc with arity one (i.e. taking one argument) can be used to explicitly validate -each parameter value. In that case, the Proc is expected to return a truthy value if the parameter -value is valid. The parameter will be considered invalid if the Proc returns a falsy value or if it -raises a StandardError. +Alternatively, a Proc with arity one (i.e. taking one argument) can be used to explicitly validate each parameter value. In that case, the Proc is expected to return a truthy value if the parameter value is valid. The parameter will be considered invalid if the Proc returns a falsy value or if it raises a StandardError. ```ruby params do @@ -1474,13 +1675,19 @@ end While Procs are convenient for single cases, consider using [Custom Validators](#custom-validators) in cases where a validation is used more than once. +Note that [allow_blank](#allow_blank) validator applies while using `:values`. In the following example the absence of `:allow_blank` does not prevent `:state` from receiving blank values because `:allow_blank` defaults to `true`. + +```ruby +params do + requires :state, type: Symbol, values: [:active, :inactive] +end +``` + #### `except_values` Parameters can be restricted from having a specific set of values with the `:except_values` option. -The `except_values` validator behaves similarly to the `values` validator in that it accepts either -an Array, a Range, or a Proc. Unlike the `values` validator, however, `except_values` only accepts -Procs with arity zero. +The `except_values` validator behaves similarly to the `values` validator in that it accepts either an Array, a Range, or a Proc. Unlike the `values` validator, however, `except_values` only accepts Procs with arity zero. ```ruby params do @@ -1501,11 +1708,24 @@ params do end ``` +#### `length` + +Parameters with types that support `#length` method can be restricted to have a specific length with the `:length` option. + +The validator accepts `:min` or `:max` or both options or only `:is` to validate that the value of the parameter is within the given limits. + +```ruby +params do + requires :code, type: String, length: { is: 2 } + requires :str, type: String, length: { min: 3 } + requires :list, type: [Integer], length: { min: 3, max: 5 } + requires :hash, type: Hash, length: { max: 5 } +end +``` + #### `regexp` -Parameters can be restricted to match a specific regular expression with the `:regexp` option. If the value -does not match the regular expression an error will be returned. Note that this is true for both `requires` -and `optional` parameters. +Parameters can be restricted to match a specific regular expression with the `:regexp` option. If the value does not match the regular expression an error will be returned. Note that this is true for both `requires` and `optional` parameters. ```ruby params do @@ -1640,8 +1860,7 @@ namespace :statuses do end ``` -The `namespace` method has a number of aliases, including: `group`, `resource`, -`resources`, and `segment`. Use whichever reads the best for your API. +The `namespace` method has a number of aliases, including: `group`, `resource`, `resources`, and `segment`. Use whichever reads the best for your API. You can conveniently define a route parameter as a namespace using `route_param`. @@ -1676,10 +1895,10 @@ end ### Custom Validators ```ruby -class AlphaNumeric < Grape::Validations::Base +class AlphaNumeric < Grape::Validations::Validators::Base def validate_param!(attr_name, params) unless params[attr_name] =~ /\A[[:alnum:]]+\z/ - fail Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: 'must consist of alpha-numeric characters' + raise Grape::Exceptions::Validation.new params: [@scope.full_name(attr_name)], message: 'must consist of alpha-numeric characters' end end end @@ -1694,10 +1913,10 @@ end You can also create custom classes that take parameters. ```ruby -class Length < Grape::Validations::Base +class Length < Grape::Validations::Validators::Base def validate_param!(attr_name, params) unless params[attr_name].length <= @option - fail Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: "must be at the most #{@option} characters long" + raise Grape::Exceptions::Validation.new params: [@scope.full_name(attr_name)], message: "must be at the most #{@option} characters long" end end end @@ -1712,7 +1931,7 @@ end You can also create custom validation that use request to validate the attribute. For example if you want to have parameters that are available to only admins, you can do the following. ```ruby -class Admin < Grape::Validations::Base +class Admin < Grape::Validations::Validators::Base def validate(request) # return if the param we are checking was not in request # @attrs is a list containing the attribute we are currently validating @@ -1723,7 +1942,7 @@ class Admin < Grape::Validations::Base return unless @option # check if user is admin or not # as an example get a token from request and check if it's admin or not - fail Grape::Exceptions::Validation, params: @attrs, message: 'Can not set admin-only field.' unless request.headers['X-Access-Token'] == 'admin' + raise Grape::Exceptions::Validation.new params: @attrs, message: 'Can not set admin-only field.' unless request.headers['X-Access-Token'] == 'admin' end end ``` @@ -1796,8 +2015,7 @@ end ### 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. +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. In case your app enforces available locales only and :en is not included in your available locales, Grape cannot fall back to English and will return the translation key for the error message. To avoid this behaviour, either provide a translation for your default locale or add :en to your available locales. @@ -1822,6 +2040,16 @@ params do end ``` +#### `length` + +```ruby +params do + requires :code, type: String, length: { is: 2, message: 'code is expected to be exactly 2 characters long' } + requires :str, type: String, length: { min: 5, message: 'str is expected to be at least 5 characters long' } + requires :list, type: [Integer], length: { min: 2, max: 3, message: 'list is expected to have between 2 and 3 elements' } +end +``` + #### `all_or_none_of` ```ruby @@ -1930,6 +2158,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 @@ -1957,8 +2219,9 @@ curl -H "secret_PassWord: swordfish" ... The header name will have been normalized for you. -- In the `header` helper names will be coerced into a capitalized kebab case. -- In the `env` collection they appear in all uppercase, in snake case, and prefixed with 'HTTP_'. +- In the `header` helper names will be coerced into a downcased kebab case as `secret-password` if using Rack 3. +- In the `header` helper names will be coerced into a capitalized kebab case as `Secret-PassWord` if using Rack < 3. +- In the `env` collection they appear in all uppercase, in snake case, and prefixed with 'HTTP_' as `HTTP_SECRET_PASSWORD` The header name will have been normalized per HTTP standards defined in [RFC2616 Section 4.2](https://www.w3.org/Protocols/rfc2616/rfc2616-sec4.html#sec4.2) regardless of what is being sent by a client. @@ -1997,10 +2260,10 @@ end # is NOT the same as -get ':status' do # this makes param[:status] available +get ':status' do # this makes params[:status] available end -# This will make both param[:status_id] and param[:id] available +# This will make both params[:status_id] and params[:id] available get 'statuses/:status_id/reviews/:id' do end @@ -2028,8 +2291,7 @@ namespace ':id' do end ``` -Optionally, you can define requirements for your named route parameters using regular -expressions on namespace or endpoint. The route will match only if all requirements are met. +Optionally, you can define requirements for your named route parameters using regular expressions on namespace or endpoint. The route will match only if all requirements are met. ```ruby get ':id', requirements: { id: /[0-9]*/ } do @@ -2047,8 +2309,7 @@ end ## Helpers -You can define helper methods that your endpoints can use with the `helpers` -macro by either giving a block or an array of modules. +You can define helper methods that your endpoints can use with the `helpers` macro by either giving a block or an array of modules. ```ruby module StatusHelpers @@ -2182,6 +2443,18 @@ params do end ``` +If documentation isn't needed (for instance, it is an internal API), documentation can be disabled. + +```ruby +class API < Grape::API + do_not_document! + + # endpoints... +end +``` + +In this case, Grape won't create objects related to documentation which are retained in RAM forever. + ## Cookies You can set, get and delete your cookies very simply using `cookies` method. @@ -2275,11 +2548,36 @@ end API.recognize_path '/statuses' ``` +Since version `2.1.0`, the `recognize_path` method takes into account the parameters type to determine which endpoint should match with given path. + +```ruby +class Books < Grape::API + resource :books do + route_param :id, type: Integer do + # GET /books/:id + get do + #... + end + end + + resource :share do + # POST /books/share + post do + # .... + end + end + end +end + +API.recognize_path '/books/1' # => /books/:id +API.recognize_path '/books/share' # => /books/share +API.recognize_path '/books/other' # => nil +``` + + ## Allowed Methods -When you add a `GET` route for a resource, a route for the `HEAD` -method will also be added automatically. You can disable this -behavior with `do_not_route_head!`. +When you add a `GET` route for a resource, a route for the `HEAD` method will also be added automatically. You can disable this behavior with `do_not_route_head!`. ``` ruby class API < Grape::API @@ -2291,11 +2589,7 @@ class API < Grape::API end ``` -When you add a route for a resource, a route for the `OPTIONS` -method will also be added. The response to an OPTIONS request will -include an "Allow" header listing the supported methods. If the resource -has `before` and `after` callbacks they will be executed, but no other callbacks will -run. +When you add a route for a resource, a route for the `OPTIONS` method will also be added. The response to an OPTIONS request will include an "Allow" header listing the supported methods. If the resource has `before` and `after` callbacks they will be executed, but no other callbacks will run. ```ruby class API < Grape::API @@ -2324,10 +2618,7 @@ curl -v -X OPTIONS http://localhost:3000/rt_count You can disable this behavior with `do_not_route_options!`. -If a request for a resource is made with an unsupported HTTP method, an -HTTP 405 (Method Not Allowed) response will be returned. If the resource -has `before` callbacks they will be executed, but no other callbacks will -run. +If a request for a resource is made with an unsupported HTTP method, an HTTP 405 (Method Not Allowed) response will be returned. If the resource has `before` callbacks they will be executed, but no other callbacks will run. ``` shell curl -X DELETE -v http://localhost:3000/rt_count/ @@ -2353,8 +2644,7 @@ Anything that responds to `#to_s` can be given as a first argument to `error!`. error! :not_found, 404 ``` -You can also return JSON formatted objects by raising error! and passing a hash -instead of a message. +You can also return JSON formatted objects by raising error! and passing a hash instead of a message. ```ruby error!({ error: 'unexpected error', detail: 'missing widget' }, 500) @@ -2419,8 +2709,7 @@ route :any, '*path' do end ``` -It is very crucial to __define this endpoint at the very end of your API__, as it -literally accepts every request. +It is very crucial to __define this endpoint at the very end of your API__, as it literally accepts every request. ## Exception Handling @@ -2446,6 +2735,14 @@ class Twitter::API < Grape::API end ``` +If you want to customize the shape of grape exceptions returned to the user, to match your `:all` handler for example, you can pass a block to `rescue_from :grape_exceptions`. + +```ruby +rescue_from :grape_exceptions do |e| + error!(e, e.status) +end +``` + You can also rescue specific exceptions. ```ruby @@ -2654,33 +2951,11 @@ Any exception that is not subclass of `StandardError` should be rescued explicit Usually it is not a case for an application logic as such errors point to problems in Ruby runtime. This is following [standard recommendations for exceptions handling](https://ruby-doc.org/core/Exception.html). -### Rails 3.x - -When mounted inside containers, such as Rails 3.x, errors such as "404 Not Found" or -"406 Not Acceptable" will likely be handled and rendered by Rails handlers. For instance, -accessing a nonexistent route "/api/foo" raises a 404, which inside rails will ultimately -be translated to an `ActionController::RoutingError`, which most likely will get rendered -to a HTML error page. - -Most APIs will enjoy preventing downstream handlers from handling errors. You may set the -`:cascade` option to `false` for the entire API or separately on specific `version` definitions, -which will remove the `X-Cascade: true` header from API responses. - -```ruby -cascade false -``` - -```ruby -version 'v1', using: :header, vendor: 'twitter', cascade: false -``` - ## Logging -`Grape::API` provides a `logger` method which by default will return an instance of the `Logger` -class from Ruby's standard library. +`Grape::API` provides a `logger` method which by default will return an instance of the `Logger` class from Ruby's standard library. -To log messages from within an endpoint, you need to define a helper to make the logger -available in the endpoint context. +To log messages from within an endpoint, you need to define a helper to make the logger available in the endpoint context. ```ruby class API < Grape::API @@ -2729,9 +3004,7 @@ For similar to Rails request logging try the [grape_logging](https://github.com/ ## API Formats -Your API can declare which content-types to support by using `content_type`. If you do not specify any, Grape will support -_XML_, _JSON_, _BINARY_, and _TXT_ content-types. The default format is `:txt`; you can change this with `default_format`. -Essentially, the two APIs below are equivalent. +Your API can declare which content-types to support by using `content_type`. If you do not specify any, Grape will support _XML_, _JSON_, _BINARY_, and _TXT_ content-types. The default format is `:txt`; you can change this with `default_format`. Essentially, the two APIs below are equivalent. ```ruby class Twitter::API < Grape::API @@ -2750,9 +3023,7 @@ class Twitter::API < Grape::API end ``` -If you declare any `content_type` whatsoever, the Grape defaults will be overridden. For example, the following API will only -support the `:xml` and `:rss` content-types, but not `:txt`, `:json`, or `:binary`. Importantly, this means the `:txt` -default format is not supported! So, make sure to set a new `default_format`. +If you declare any `content_type` whatsoever, the Grape defaults will be overridden. For example, the following API will only support the `:xml` and `:rss` content-types, but not `:txt`, `:json`, or `:binary`. Importantly, this means the `:txt` default format is not supported! So, make sure to set a new `default_format`. ```ruby class Twitter::API < Grape::API @@ -2763,8 +3034,7 @@ class Twitter::API < Grape::API end ``` -Serialization takes place automatically. For example, you do not have to call `to_json` in each JSON API endpoint -implementation. The response format (and thus the automatic serialization) is determined in the following order: +Serialization takes place automatically. For example, you do not have to call `to_json` in each JSON API endpoint implementation. The response format (and thus the automatic serialization) is determined in the following order: * Use the file extension, if specified. If the file is .json, choose the JSON format. * Use the value of the `format` parameter in the query string, if specified. * Use the format set by the `format` option, if specified. @@ -2787,20 +3057,15 @@ class MultipleFormatAPI < Grape::API end ``` -* `GET /hello` (with an `Accept: */*` header) does not have an extension or a `format` parameter, so it will respond with - JSON (the default format). +* `GET /hello` (with an `Accept: */*` header) does not have an extension or a `format` parameter, so it will respond with JSON (the default format). * `GET /hello.xml` has a recognized extension, so it will respond with XML. * `GET /hello?format=xml` has a recognized `format` parameter, so it will respond with XML. -* `GET /hello.xml?format=json` has a recognized extension (which takes precedence over the `format` parameter), so it will - respond with XML. -* `GET /hello.xls` (with an `Accept: */*` header) has an extension, but that extension is not recognized, so it will respond - with JSON (the default format). -* `GET /hello.xls` with an `Accept: application/xml` header has an unrecognized extension, but the `Accept` header - corresponds to a recognized format, so it will respond with XML. -* `GET /hello.xls` with an `Accept: text/plain` header has an unrecognized extension *and* an unrecognized `Accept` header, - so it will respond with JSON (the default format). - -You can override this process explicitly by specifying `env['api.format']` in the API itself. +* `GET /hello.xml?format=json` has a recognized extension (which takes precedence over the `format` parameter), so it will respond with XML. +* `GET /hello.xls` (with an `Accept: */*` header) has an extension, but that extension is not recognized, so it will respond with JSON (the default format). +* `GET /hello.xls` with an `Accept: application/xml` header has an unrecognized extension, but the `Accept` header corresponds to a recognized format, so it will respond with XML. +* `GET /hello.xls` with an `Accept: text/plain` header has an unrecognized extension *and* an unrecognized `Accept` header, so it will respond with JSON (the default format). + +You can override this process explicitly by calling `api_format` in the API itself. For example, the following API will let you upload arbitrary files and return their contents as an attachment with the correct MIME type. ```ruby @@ -2808,15 +3073,14 @@ class Twitter::API < Grape::API post 'attachment' do filename = params[:file][:filename] content_type MIME::Types.type_for(filename)[0].to_s - env['api.format'] = :binary # there's no formatter for :binary, data will be returned "as is" + api_format :binary # there's no formatter for :binary, data will be returned "as is" header 'Content-Disposition', "attachment; filename*=UTF-8''#{CGI.escape(filename)}" params[:file][:tempfile].read end end ``` -You can have your API only respond to a single format with `format`. If you use this, the API will **not** respond to file -extensions other than specified in `format`. For example, consider the following API. +You can have your API only respond to a single format with `format`. If you use this, the API will **not** respond to file extensions other than specified in `format`. For example, consider the following API. ```ruby class SingleFormatAPI < Grape::API @@ -2831,14 +3095,10 @@ end * `GET /hello` will respond with JSON. * `GET /hello.json` will respond with JSON. * `GET /hello.xml`, `GET /hello.foobar`, or *any* other extension will respond with an HTTP 404 error code. -* `GET /hello?format=xml` will respond with an HTTP 406 error code, because the XML format specified by the request parameter - is not supported. -* `GET /hello` with an `Accept: application/xml` header will still respond with JSON, since it could not negotiate a - recognized content-type from the headers and JSON is the effective default. +* `GET /hello?format=xml` will respond with an HTTP 406 error code, because the XML format specified by the request parameter is not supported. +* `GET /hello` with an `Accept: application/xml` header will still respond with JSON, since it could not negotiate a recognized content-type from the headers and JSON is the effective default. -The formats apply to parsing, too. The following API will only respond to the JSON content-type and will not parse any other -input than `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related` and -`multipart/mixed`. All other requests will fail with an HTTP 406 error code. +The formats apply to parsing, too. The following API will only respond to the JSON content-type and will not parse any other input than `application/json`, `application/x-www-form-urlencoded`, `multipart/form-data`, `multipart/related` and `multipart/mixed`. All other requests will fail with an HTTP 406 error code. ```ruby class Twitter::API < Grape::API @@ -2894,23 +3154,18 @@ end Built-in formatters are the following. * `:json`: use object's `to_json` when available, otherwise call `MultiJson.dump` -* `:xml`: use object's `to_xml` when available, usually via `MultiXml`, otherwise call `to_s` +* `:xml`: use object's `to_xml` when available, usually via `MultiXml` * `:txt`: use object's `to_txt` when available, otherwise `to_s` * `:serializable_hash`: use object's `serializable_hash` when available, otherwise fallback to `:json` * `:binary`: data will be returned "as is" -If a body is present in a request to an API, with a Content-Type header value that is of an unsupported type a -"415 Unsupported Media Type" error code will be returned by Grape. +If a body is present in a request to an API, with a Content-Type header value that is of an unsupported type a "415 Unsupported Media Type" error code will be returned by Grape. -Response statuses that indicate no content as defined by [Rack](https://github.com/rack) -[here](https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L567) -will bypass serialization and the body entity - though there should be none - -will not be modified. +Response statuses that indicate no content as defined by [Rack](https://github.com/rack) [here](https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L567) will bypass serialization and the body entity - though there should be none - will not be modified. ### JSONP -Grape supports JSONP via [Rack::JSONP](https://github.com/rack/rack-contrib), part of the -[rack-contrib](https://github.com/rack/rack-contrib) gem. Add `rack-contrib` to your `Gemfile`. +Grape supports JSONP via [Rack::JSONP](https://github.com/rack/rack-contrib), part of the [rack-contrib](https://github.com/rack/rack-contrib) gem. Add `rack-contrib` to your `Gemfile`. ```ruby require 'rack/contrib' @@ -2926,9 +3181,7 @@ end ### CORS -Grape supports CORS via [Rack::CORS](https://github.com/cyu/rack-cors), part of the -[rack-cors](https://github.com/cyu/rack-cors) gem. Add `rack-cors` to your `Gemfile`, -then use the middleware in your config.ru file. +Grape supports CORS via [Rack::CORS](https://github.com/cyu/rack-cors), part of the [rack-cors](https://github.com/cyu/rack-cors) gem. Add `rack-cors` to your `Gemfile`, then use the middleware in your config.ru file. ```ruby require 'rack/cors' @@ -2946,8 +3199,7 @@ run Twitter::API ## Content-type -Content-type is set by the formatter. You can override the content-type of the response at runtime -by setting the `Content-Type` header. +Content-type is set by the formatter. You can override the content-type of the response at runtime by setting the `Content-Type` header. ```ruby class API < Grape::API @@ -2960,16 +3212,12 @@ end ## API Data Formats -Grape accepts and parses input data sent with the POST and PUT methods as described in the Parameters -section above. It also supports custom data formats. You must declare additional content-types via -`content_type` and optionally supply a parser via `parser` unless a parser is already available within -Grape to enable a custom format. Such a parser can be a function or a class. +Grape accepts and parses input data sent with the POST and PUT methods as described in the Parameters section above. It also supports custom data formats. You must declare additional content-types via `content_type` and optionally supply a parser via `parser` unless a parser is already available within Grape to enable a custom format. Such a parser can be a function or a class. With a parser, parsed data is available "as-is" in `env['api.request.body']`. Without a parser, data is available "as-is" and in `env['api.request.input']`. -The following example is a trivial parser that will assign any input with the "text/custom" content-type -to `:value`. The parameter will be available via `params[:value]` inside the API call. +The following example is a trivial parser that will assign any input with the "text/custom" content-type to `:value`. The parameter will be available via `params[:value]` inside the API call. ```ruby module CustomParser @@ -3003,9 +3251,7 @@ Grape uses `JSON` and `ActiveSupport::XmlMini` for JSON and XML parsing by defau ## RESTful Model Representations -Grape supports a range of ways to present your data with some help from a generic `present` method, -which accepts two arguments: the object to be presented and the options associated with it. The options -hash may include `:with`, which defines the entity to expose. +Grape supports a range of ways to present your data with some help from a generic `present` method, which accepts two arguments: the object to be presented and the options associated with it. The options hash may include `:with`, which defines the entity to expose. ### Grape Entities @@ -3084,8 +3330,7 @@ The response will be } ``` -In addition to separately organizing entities, it may be useful to put them as namespaced -classes underneath the model they represent. +In addition to separately organizing entities, it may be useful to put them as namespaced classes underneath the model they represent. ```ruby class Status @@ -3099,11 +3344,7 @@ class Status end ``` -If you organize your entities this way, Grape will automatically detect the `Entity` class and -use it to present your models. In this example, if you added `present Status.new` to your endpoint, -Grape will automatically detect that there is a `Status::Entity` class and use that as the -representative entity. This can still be overridden by using the `:with` option or an explicit -`represents` call. +If you organize your entities this way, Grape will automatically detect the `Entity` class and use it to present your models. In this example, if you added `present Status.new` to your endpoint, Grape will automatically detect that there is a `Status::Entity` class and use that as the representative entity. This can still be overridden by using the `:with` option or an explicit `represents` call. You can present `hash` with `Grape::Presenters::Presenter` to keep things consistent. @@ -3136,15 +3377,11 @@ You can use [Roar](https://github.com/apotonick/roar) to render HAL or Collectio ### Rabl -You can use [Rabl](https://github.com/nesquena/rabl) templates with the help of the -[grape-rabl](https://github.com/ruby-grape/grape-rabl) gem, which defines a custom Grape Rabl -formatter. +You can use [Rabl](https://github.com/nesquena/rabl) templates with the help of the [grape-rabl](https://github.com/ruby-grape/grape-rabl) gem, which defines a custom Grape Rabl formatter. ### Active Model Serializers -You can use [Active Model Serializers](https://github.com/rails-api/active_model_serializers) serializers with the help of the -[grape-active_model_serializers](https://github.com/jrhe/grape-active_model_serializers) gem, which defines a custom Grape AMS -formatter. +You can use [Active Model Serializers](https://github.com/rails-api/active_model_serializers) serializers with the help of the [grape-active_model_serializers](https://github.com/jrhe/grape-active_model_serializers) gem, which defines a custom Grape AMS formatter. ## Sending Raw or No Data @@ -3173,9 +3410,18 @@ end Use `body false` to return `204 No Content` without any data or content-type. -You can also set the response to a file with `sendfile`. This works with the -[Rack::Sendfile](https://www.rubydoc.info/gems/rack/Rack/Sendfile) middleware to optimally send -the file through your web server software. +If you want to empty the body with an HTTP status code other than `204 No Content`, you can override the status code after specifying `body false` as follows + +```ruby +class API < Grape::API + get '/' do + body false + status 304 + end +end +``` + +You can also set the response to a file with `sendfile`. This works with the [Rack::Sendfile](https://www.rubydoc.info/gems/rack/Rack/Sendfile) middleware to optimally send the file through your web server software. ```ruby class API < Grape::API @@ -3217,11 +3463,9 @@ end ## Authentication -### Basic and Digest Auth +### Basic Auth -Grape has built-in Basic and Digest authentication (the given `block` -is executed in the context of the current `Endpoint`). Authentication -applies to the current namespace and any children, but not parents. +Grape has built-in Basic authentication (the given `block` is executed in the context of the current `Endpoint`). Authentication applies to the current namespace and any children, but not parents. ```ruby http_basic do |username, password| @@ -3230,24 +3474,15 @@ http_basic do |username, password| end ``` -```ruby -http_digest({ realm: 'Test Api', opaque: 'app secret' }) do |username| - # lookup the user's password here -end -``` - ### Register custom middleware for authentication -Grape can use custom Middleware for authentication. How to implement these -Middleware have a look at `Rack::Auth::Basic` or similar implementations. - +Grape can use custom Middleware for authentication. How to implement these Middleware have a look at `Rack::Auth::Basic` or similar implementations. For registering a Middleware you need the following options: * `label` - the name for your authenticator to use it later * `MiddlewareClass` - the MiddlewareClass to use for authentication -* `option_lookup_proc` - A Proc with one Argument to lookup the options at -runtime (return value is an `Array` as Parameter for the Middleware). +* `option_lookup_proc` - A Proc with one Argument to lookup the options at runtime (return value is an `Array` as Parameter for the Middleware). Example: @@ -3271,7 +3506,7 @@ You can access the controller params, headers, and helpers through the context w Grape routes can be reflected at runtime. This can notably be useful for generating documentation. -Grape exposes arrays of API versions and compiled routes. Each route contains a `route_prefix`, `route_version`, `route_namespace`, `route_method`, `route_path` and `route_params`. You can add custom route settings to the route metadata with `route_setting`. +Grape exposes arrays of API versions and compiled routes. Each route contains a `prefix`, `version`, `namespace`, `method` and `params`. You can add custom route settings to the route metadata with `route_setting`. ```ruby class TwitterAPI < Grape::API @@ -3294,14 +3529,14 @@ TwitterAPI::routes[0].description # => 'Includes custom settings.' TwitterAPI::routes[0].settings[:custom] # => { key: 'value' } ``` -Note that `Route#route_xyz` methods have been deprecated since 0.15.0. +Note that `Route#route_xyz` methods have been deprecated since 0.15.0 and removed since 2.0.1. Please use `Route#xyz` instead. Note that difference of `Route#options` and `Route#settings`. -The `options` can be referred from your route, it should be set by specifing key and value on verb methods such as `get`, `post` and `put`. -The `settings` can also be referred from your route, but it should be set by specifing key and value on `route_setting`. +The `options` can be referred from your route, it should be set by specifying key and value on verb methods such as `get`, `post` and `put`. +The `settings` can also be referred from your route, but it should be set by specifying key and value on `route_setting`. ## Current Route and Endpoint @@ -3314,15 +3549,12 @@ class MyAPI < Grape::API requires :id, type: Integer, desc: 'Identity.' end get 'params/:id' do - route.route_params[params[:id]] # yields the parameter description + route.params[params[:id]] # yields the parameter description end end ``` -The current endpoint responding to the request is `self` within the API block -or `env['api.endpoint']` elsewhere. The endpoint has some interesting properties, -such as `source` which gives you access to the original code block of the API -implementation. This can be particularly useful for building a logger middleware. +The current endpoint responding to the request is `self` within the API block or `env['api.endpoint']` elsewhere. The endpoint has some interesting properties, such as `source` which gives you access to the original code block of the API implementation. This can be particularly useful for building a logger middleware. ```ruby class ApiLogger < Grape::Middleware::Base @@ -3336,10 +3568,8 @@ end ## Before, After and Finally -Blocks can be executed before or after every API call, using `before`, `after`, -`before_validation` and `after_validation`. -If the API fails the `after` call will not be triggered, if you need code to execute for sure -use the `finally`. +Blocks can be executed before or after every API call, using `before`, `after`, `before_validation` and `after_validation`. +If the API fails the `after` call will not be triggered, if you need code to execute for sure use the `finally`. Before and after callbacks execute in the following order: @@ -3353,13 +3583,9 @@ Before and after callbacks execute in the following order: Steps 4, 5 and 6 only happen if validation succeeds. -If a request for a resource is made with an unsupported HTTP method (returning -HTTP 405) only `before` callbacks will be executed. The remaining callbacks will -be bypassed. +If a request for a resource is made with an unsupported HTTP method (returning HTTP 405) only `before` callbacks will be executed. The remaining callbacks will be bypassed. -If a request for a resource is made that triggers the built-in `OPTIONS` handler, -only `before` and `after` callbacks will be executed. The remaining callbacks will -be bypassed. +If a request for a resource is made that triggers the built-in `OPTIONS` handler, only `before` and `after` callbacks will be executed. The remaining callbacks will be bypassed. For example, using a simple `before` block to set a header. @@ -3504,11 +3730,7 @@ Instead of altering a response, you can also terminate and rewrite it from any c ## Anchoring -Grape by default anchors all request paths, which means that the request URL -should match from start to end to match, otherwise a `404 Not Found` is -returned. However, this is sometimes not what you want, because it is not always -known upfront what can be expected from the call. This is because Rack-mount by -default anchors requests to match from the start to the end, or not at all. +Grape by default anchors all request paths, which means that the request URL should match from start to end to match, otherwise a `404 Not Found` is returned. However, this is sometimes not what you want, because it is not always known upfront what can be expected from the call. This is because Rack-mount by default anchors requests to match from the start to the end, or not at all. Rails solves this problem by using a `anchor: false` option in your routes. In Grape this option can be used as well when a method is defined. @@ -3524,12 +3746,44 @@ class TwitterAPI < Grape::API end ``` -This will match all paths starting with '/statuses/'. There is one caveat though: -the `params[:status]` parameter only holds the first part of the request url. -Luckily this can be circumvented by using the described above syntax for path -specification and using the `PATH_INFO` Rack environment variable, using -`env['PATH_INFO']`. This will hold everything that comes after the '/statuses/' -part. +This will match all paths starting with '/statuses/'. There is one caveat though: the `params[:status]` parameter only holds the first part of the request url. +Luckily this can be circumvented by using the described above syntax for path specification and using the `PATH_INFO` Rack environment variable, using `env['PATH_INFO']`. This will hold everything that comes after the '/statuses/' part. + +## Instance Variables + +You can use instance variables to pass information across the various stages of a request. An instance variable set within a `before` validator is accessible within the endpoint's code and can also be utilized within the `rescue_from` handler. + +```ruby +class TwitterAPI < Grape::API + before do + @var = 1 + end + + get '/' do + puts @var # => 1 + raise + end + + rescue_from :all do + puts @var # => 1 + end +end +``` + +The values of instance variables cannot be shared among various endpoints within the same API. This limitation arises due to Grape generating a new instance for each request made. Consequently, instance variables set within an endpoint during one request differ from those set during a subsequent request, as they exist within separate instances. + +```ruby +class TwitterAPI < Grape::API + get '/first' do + @var = 1 + puts @var # => 1 + end + + get '/second' do + puts @var # => nil + end +end +``` ## Using Custom Middleware @@ -3586,6 +3840,14 @@ You can access the controller params, headers, and helpers through the context w Note that when you're using Grape mounted on Rails you don't have to use Rails middleware because it's already included into your middleware stack. You only have to implement the helpers to access the specific `env` variable. +If you are using a custom application that is inherited from `Rails::Application` and need to insert a new middleware among the ones initiated via Rails, you will need to register it manually in your custom application class. + +```ruby +class Company::Application < Rails::Application + config.middleware.insert_before(Rack::Attack, Middleware::ApiLogger) +end +``` + ### Remote IP By default you can access remote IP with `request.ip`. This is the remote IP address implemented by Rack. Sometimes it is desirable to get the remote IP [Rails-style](http://stackoverflow.com/questions/10997005/whats-the-difference-between-request-remote-ip-and-request-ip-in-rails) with `ActionDispatch::RemoteIp`. @@ -3619,7 +3881,7 @@ Use `rack-test` and define your API as `app`. You can test a Grape API with RSpec by making HTTP requests and examining the response. ```ruby -require 'spec_helper' + describe Twitter::API do include Rack::Test::Methods @@ -3730,8 +3992,7 @@ describe Twitter::API do end ``` -In Rails, HTTP request tests would go into the `spec/requests` group. You may want your API code to go into -`app/api` - you can match that layout under `spec` by adding the following in `spec/rails_helper.rb`. +In Rails, HTTP request tests would go into the `spec/requests` group. You may want your API code to go into `app/api` - you can match that layout under `spec` by adding the following in `spec/rails_helper.rb`. ```ruby RSpec.configure do |config| @@ -3765,10 +4026,7 @@ end ### Stubbing Helpers -Because helpers are mixed in based on the context when an endpoint is defined, it can -be difficult to stub or mock them for testing. The `Grape::Endpoint.before_each` method -can help by allowing you to define behavior on the endpoint that will run before every -request. +Because helpers are mixed in based on the context when an endpoint is defined, it can be difficult to stub or mock them for testing. The `Grape::Endpoint.before_each` method can help by allowing you to define behavior on the endpoint that will run before every request. ```ruby describe 'an endpoint that needs helpers stubbed' do @@ -3890,11 +4148,11 @@ Grape integrates with following third-party tools: * **[Skylight](https://www.skylight.io/)** - [skylight](https://github.com/skylightio/skylight-ruby) gem, [documentation](https://docs.skylight.io/grape/) * **[AppSignal](https://www.appsignal.com)** - [appsignal-ruby](https://github.com/appsignal/appsignal-ruby) gem, [documentation](http://docs.appsignal.com/getting-started/supported-frameworks.html#grape) * **[ElasticAPM](https://www.elastic.co/products/apm)** - [elastic-apm](https://github.com/elastic/apm-agent-ruby) gem, [documentation](https://www.elastic.co/guide/en/apm/agent/ruby/3.x/getting-started-rack.html#getting-started-grape) +* **[Datadog APM](https://docs.datadoghq.com/tracing/)** - [ddtrace](https://github.com/datadog/dd-trace-rb) gem, [documentation](https://docs.datadoghq.com/tracing/setup_overview/setup/ruby/#grape) ## Contributing to Grape -Grape is work of hundreds of contributors. You're encouraged to submit pull requests, propose -features and discuss issues. +Grape is work of hundreds of contributors. You're encouraged to submit pull requests, propose features and discuss issues. See [CONTRIBUTING](CONTRIBUTING.md). diff --git a/RELEASING.md b/RELEASING.md index 1d40997ce..9774d9f52 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,7 +12,7 @@ bundle install rake ``` -Check that the last build succeeded in [Travis CI](https://travis-ci.org/ruby-grape/grape) for all supported platforms. +Double-check that the [last build succeeded](https://github.com/ruby-grape/grape/actions) for all supported platforms. Those with r/w permissions to the [master Grape repository](https://github.com/ruby-grape/grape) generally have large Grape-based projects. Point one to Grape HEAD and run all your API tests to catch any obvious regressions. @@ -20,11 +20,6 @@ Those with r/w permissions to the [master Grape repository](https://github.com/r gem grape, github: 'ruby-grape/grape' ``` -Increment the version, modify [lib/grape/version.rb](lib/grape/version.rb). - -* Increment the third number if the release has bug fixes and/or very minor features, only (eg. change `0.5.1` to `0.5.2`). -* Increment the second number if the release contains major features or breaking API changes (eg. change `0.5.1` to `0.6.0`). - Modify the "Stable Release" section in [README.md](README.md). Change the text to reflect that this is going to be the documentation for a stable release. Remove references to the previous release of Grape. Keep the file open, you'll have to undo this change after the release. ``` @@ -44,7 +39,7 @@ Remove the line with "Your contribution here.", since there will be no more cont Commit your changes. ``` -git add README.md CHANGELOG.md lib/grape/version.rb +git add README.md CHANGELOG.md git commit -m "Preparing for release, 0.6.0." git push origin master ``` diff --git a/Rakefile b/Rakefile index 58788aa82..2d79797f7 100644 --- a/Rakefile +++ b/Rakefile @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'rubygems' -require 'bundler' -Bundler.setup :default, :test, :development +require('rubygems') +require('bundler') +Bundler.setup(:default, :test, :development) Bundler::GemHelper.install_tasks -require 'rspec/core/rake_task' +require('rspec/core/rake_task') RSpec::Core::RakeTask.new(:spec) do |spec| spec.pattern = 'spec/**/*_spec.rb' spec.exclude_pattern = 'spec/integration/**/*_spec.rb' @@ -17,11 +17,11 @@ RSpec::Core::RakeTask.new(:rcov) do |spec| spec.rcov = true end -task :spec +task(:spec) -require 'rainbow/ext/string' unless String.respond_to?(:color) +require('rainbow/ext/string') unless String.respond_to?(:color) -require 'rubocop/rake_task' +require('rubocop/rake_task') RuboCop::RakeTask.new -task default: %i[rubocop spec] +task(default: %i[rubocop spec]) diff --git a/UPGRADING.md b/UPGRADING.md index 591fa51ad..2ace6622d 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -1,6 +1,451 @@ Upgrading Grape =============== +### Upgrading to >= 2.4.0 + +#### Grape::Http::Headers, Grape::Util::Lazy::Object + +Both have been removed. See [2554](https://github.com/ruby-grape/grape/pull/2554). +Here are the notable changes: + +- Constants like `HTTP_ACCEPT` have been replaced by their literal value. +- `SUPPORTED_METHODS` has been moved to `Grape` module. +- `HTTP_HEADERS` has been moved to `Grape::Request` and renamed `KNOWN_HEADERS`. The last has been refreshed with new headers, and it's not lazy anymore. +- `SUPPORTED_METHODS_WITHOUT_OPTIONS` and `find_supported_method` have been removed. + +### Grape::Middleware::Base + +- Constant `TEXT_HTML` has been removed in favor of using literal string 'text/html'. +- `rack_request` and `query_params` have been added. Feel free to call these in your middlewares. + +#### Params Builder + +- Passing a class to `build_with` or `Grape.config.param_builder` has been deprecated in favor of a symbolized short_name. See `SHORTNAME_LOOKUP` in [params_builder](lib/grape/params_builder.rb). +- Including Grape's extensions like `Grape::Extensions::Hashie::Mash::ParamBuilder` has been deprecated in favor of using `build_with` at the route level. + +#### Accept Header Negotiation Harmonized + +[Accept](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept) header is now fully interpreted through `Rack::Utils.best_q_match` which is following [RFC2616 14.1](https://datatracker.ietf.org/doc/html/rfc2616#section-14.1). Since [Grape 2.1.0](https://github.com/ruby-grape/grape/blob/master/CHANGELOG.md#210-20240615), the [header versioning strategy](https://github.com/ruby-grape/grape?tab=readme-ov-file#header) was adhering to it, but `Grape::Middleware::Formatter` never did. + +Your API might act differently since it will strictly follow the [RFC2616 14.1](https://datatracker.ietf.org/doc/html/rfc2616#section-14.1) when interpreting the `Accept` header. Here are the differences: + +###### Invalid or missing quality ranking +The following used to yield `application/xml` and now will yield `application/json` as the preferred media type: +- `application/json;q=invalid,application/xml;q=0.5` +- `application/json,application/xml;q=1.0` + +For the invalid case, the value `invalid` was automatically `to_f` and `invalid.to_f` equals `0.0`. Now, since it doesn't match [Rack's regex](https://github.com/rack/rack/blob/3-1-stable/lib/rack/utils.rb#L138), its interpreted as non provided and its quality ranking equals 1.0. + +For the non provided case, 1.0 was automatically assigned and in a case of multiple best matches, the first was returned based on Ruby's sort_by `quality`. Now, 1.0 is still assigned and the last is returned in case of multiple best matches. See [Rack's implementation](https://github.com/rack/rack/blob/e8f47608668d507e0f231a932fa37c9ca551c0a5/lib/rack/utils.rb#L167) of the RFC. + +###### Considering the closest generic when vendor tree +Excluding the [header versioning strategy](https://github.com/ruby-grape/grape?tab=readme-ov-file#header), whenever a media type with the [vendor tree](https://datatracker.ietf.org/doc/html/rfc6838#section-3.2) leading facet `vnd.` like `application/vnd.api+json` was provided, Grape would also consider its closest generic when negotiating. In that case, `application/json` was added to the negotiation. Now, it will just consider the provided media types without considering any closest generics, and you'll need to [register](https://github.com/ruby-grape/grape?tab=readme-ov-file#api-formats) it. +You can find the official vendor tree registrations on [IANA](https://www.iana.org/assignments/media-types/media-types.xhtml) + +#### Custom Validators + +If you now receive an error of `'Grape::Validations.require_validator': unknown validator: your_custom_validation (Grape::Exceptions::UnknownValidator)` after upgrading to 2.4.0 then you will need to ensure that you require the `your_custom_validation` file before your Grape API code is loaded. + +See [2533](https://github.com/ruby-grape/grape/issues/2533) for more information. + +### Upgrading to >= 2.3.0 + +### `content_type` vs `api.format` inside API + +Before 2.3.0, `content_type` had priority over `env['api.format']` when set in an API, which was incorrect. The priority has been flipped and `env['api.format']` will be checked first. +In addition, the function `api_format` has been added. Instead of setting `env['api.format']` directly, you can call `api_format`. +See [#2506](https://github.com/ruby-grape/grape/pull/2506) for more information. + +#### Remove Deprecated Methods and Options + +- Deprecated `file` method has been removed. Use `send_file` or `stream`. +See [#2500](https://github.com/ruby-grape/grape/pull/2500) for more information. + +- The `except` and `proc` options have been removed from the `values` validator. Use `except_values` validator or assign `proc` directly to `values`. +See [#2501](https://github.com/ruby-grape/grape/pull/2501) for more information. + +- `Passing an options hash and a block to 'desc'` deprecation has been removed. Move all hash options to block instead. +See [#2502](https://github.com/ruby-grape/grape/pull/2502) for more information. + +### Upgrading to >= 2.2.0 + +### `Length` validator + +After Grape 2.2.0, `length` validator will only take effect for parameters with types that support `#length` method, will not throw `ArgumentError` exception. + +See [#2464](https://github.com/ruby-grape/grape/pull/2464) for more information. + +### Upgrading to >= 2.1.0 + +#### Optional Builder + +The `builder` gem dependency has been made optional as it's only used when generating XML. If your code does, add `builder` to your `Gemfile`. + +See [#2445](https://github.com/ruby-grape/grape/pull/2445) for more information. + +#### Deep Merging of Parameter Attributes + +Grape now uses `deep_merge` to combine parameter attributes within the `with` method. Previously, attributes defined at the parameter level would override those defined at the group level. +With deep merge, attributes are now combined, allowing for more detailed and nuanced API specifications. + +For example: + +```ruby +with(documentation: { in: 'body' }) do + optional :vault, documentation: { default: 33 } +end +``` + +Before it was equivalent to: + +```ruby +optional :vault, documentation: { default: 33 } +``` + +After it is an equivalent of: + +```ruby +optional :vault, documentation: { in: 'body', default: 33 } +``` + +See [#2432](https://github.com/ruby-grape/grape/pull/2432) for more information. + +#### Zeitwerk + +Grape's autoloader has been updated and it's now based on [Zeitwerk](https://github.com/fxn/zeitwerk). +If you MP (Monkey Patch) some files and you're not following the [file structure](https://github.com/fxn/zeitwerk?tab=readme-ov-file#file-structure), you might end up with a Zeitwerk error. + +See [#2363](https://github.com/ruby-grape/grape/pull/2363) for more information. + +#### Changes in rescue_from + +The `rack_response` method has been deprecated and the `error_response` method has been removed. Use `error!` instead. + +See [#2414](https://github.com/ruby-grape/grape/pull/2414) for more information. + +#### Change in parameters precedence + +When using together with `Grape::Extensions::Hash::ParamBuilder`, `route_param` takes higher precedence over a regular parameter defined with same name, which now matches the default param builder behavior. + +This was a regression introduced by [#2326](https://github.com/ruby-grape/grape/pull/2326) in Grape v1.8.0. + +```ruby +grape.configure do |config| + config.param_builder = Grape::Extensions::Hash::ParamBuilder +end + +params do + requires :foo, type: String +end +route_param :foo do + get do + { value: params[:foo] } + end +end +``` + +Request: + +```bash +curl -X POST -H "Content-Type: application/json" localhost:9292/bar -d '{"foo": "baz"}' +``` + +Response prior to v1.8.0: + +```json +{ + "value": "bar" +} +``` + +v1.8.0..v2.0.0: + +```json +{ + "value": "baz" +} +``` + +v2.1.0+: + +```json +{ + "value": "bar" +} +``` + +See [#2378](https://github.com/ruby-grape/grape/pull/2378) for details. + +#### Grape::Router::Route.route_xxx methods have been removed + +- `route_method` is accessible through `request_method` +- `route_path` is accessible through `path` +- Any other `route_xyz` are accessible through `options[xyz]` + +#### Instance variables scope + +Due to the changes done in [#2377](https://github.com/ruby-grape/grape/pull/2377), the instance variables defined inside each of the endpoints (or inside a `before` validator) are now accessible inside the `rescue_from`. The behavior of the instance variables was undefined until `2.1.0`. + +If you were using the same variable name defined inside an endpoint or `before` validator inside a `rescue_from` handler, you need to take in mind that you can start getting different values or you can be overriding values. + +Before: +```ruby +class TwitterAPI < Grape::API + before do + @var = 1 + end + + get '/' do + puts @var # => 1 + raise + end + + rescue_from :all do + puts @var # => nil + end +end +``` + +After: +```ruby +class TwitterAPI < Grape::API + before do + @var = 1 + end + + get '/' do + puts @var # => 1 + raise + end + + rescue_from :all do + puts @var # => 1 + end +end +``` + +#### Recognizing Path + +Grape now considers the types of the configured `route_params` in order to determine the endpoint that matches with the performed request. + +So taking into account this `Grape::API` class + +```ruby +class Books < Grape::API + resource :books do + route_param :id, type: Integer do + # GET /books/:id + get do + #... + end + end + + resource :share do + # POST /books/share + post do + # .... + end + end + end +end +``` + +Before: +```ruby +API.recognize_path '/books/1' # => /books/:id +API.recognize_path '/books/share' # => /books/:id +API.recognize_path '/books/other' # => /books/:id +``` + +After: +```ruby +API.recognize_path '/books/1' # => /books/:id +API.recognize_path '/books/share' # => /books/share +API.recognize_path '/books/other' # => nil +``` + +This implies that before this changes, when you performed `/books/other` and it matched with the `/books/:id` endpoint, you get a `400 Bad Request` response because the type of the provided `:id` param was not an `Integer`. However, after upgrading to version `2.1.0` you will get a `404 Not Found` response, because there is not a defined endpoint that matches with `/books/other`. + +See [#2379](https://github.com/ruby-grape/grape/pull/2379) for more information. + +### Upgrading to >= 2.0.0 + +#### Headers + +As per [rack/rack#1592](https://github.com/rack/rack/issues/1592) Rack 3 is following the HTTP/2+ semantics which require header names to be lower case. To avoid compatibility issues, starting with Grape 1.9.0, headers will be cased based on what version of Rack you are using. + +Given this request: + +```shell +curl -H "Content-Type: application/json" -H "Secret-Password: foo" ... +``` + +If you are using Rack 3 in your application then the headers will be set to: + +```ruby +{ "content-type" => "application/json", "secret-password" => "foo"} +``` + +This means if you are checking for header values in your application, you would need to change your code to use downcased keys. + +```ruby +get do + # This would use headers['Secret-Password'] in Rack < 3 + error!('Unauthorized', 401) unless headers['secret-password'] == 'swordfish' +end +``` + +See [#2355](https://github.com/ruby-grape/grape/pull/2355) for more information. + +#### Digest auth deprecation + +Digest auth has been removed along with the deprecation of `Rack::Auth::Digest` in Rack 3. + +See [#2294](https://github.com/ruby-grape/grape/issues/2294) for more information. + +### Upgrading to >= 1.7.0 + +#### Exceptions renaming + +The following exceptions has been renamed for consistency through exceptions naming : + +* `MissingGroupTypeError` => `MissingGroupType` +* `UnsupportedGroupTypeError` => `UnsupportedGroupType` + +See [#2227](https://github.com/ruby-grape/grape/pull/2227) for more information. + +#### Handling Multipart Limit Errors + +Rack supports a configurable limit on the number of files created from multipart parameters (`Rack::Utils.multipart_part_limit`) and raises an error if params are received that create too many files. If you were handling the Rack error directly, Grape now wraps that error in `Grape::Execeptions::TooManyMultipartFiles`. Additionally, Grape will return a 413 status code if the exception goes unhandled. + +### Upgrading to >= 1.6.0 + +#### Parameter renaming with :as + +Prior to 1.6.0 the [parameter renaming](https://github.com/ruby-grape/grape#renaming) with `:as` was directly touching the request payload ([`#params`](https://github.com/ruby-grape/grape#parameters)) while duplicating the old and the new key to be both available in the hash. This allowed clients to bypass any validation in case they knew the internal name of the parameter. Unfortunately, in combination with [grape-swagger](https://github.com/ruby-grape/grape-swagger) the internal name (name set with `:as`) of the parameters were documented. + +This behavior was fixed. Parameter renaming is now done when using the [`#declared(params)`](https://github.com/ruby-grape/grape#declared) parameters helper. This stops confusing validation/coercion behavior. + +Here comes an illustration of the old and new behaviour as code: + +```ruby +# (1) Rename a to b, while client sends +a+ +optional :a, type: Integer, as: :b +params = { a: 1 } +declared(params, include_missing: false) +# expected => { b: 1 } +# actual => { b: 1 } + +# (2) Rename a to b, while client sends +b+ +optional :a, type: Integer, as: :b, values: [1, 2, 3] +params = { b: '5' } +declared(params, include_missing: false) +# expected => { } (>= 1.6.0) +# actual => { b: '5' } (uncasted, unvalidated, <= 1.5.3) +``` + +Another implication of this change is the dependent parameter resolution. Prior to 1.6.0 the following code produced a `Grape::Exceptions::UnknownParameter` because `:a` was replaced by `:b`: + +```ruby +params do + optional :a, as: :b + given :a do # (<= 1.5.3 you had to reference +:b+ here to make it work) + requires :c + end +end +``` + +This code now works without any errors, as the renaming is just an internal behaviour of the `#declared(params)` parameter helper. + +See [#2189](https://github.com/ruby-grape/grape/pull/2189) for more information. + +### Upgrading to >= 1.5.3 + +#### Nil value and coercion + +Prior to 1.2.5 version passing a `nil` value for a parameter with a custom coercer would invoke the coercer, and not passing a parameter would not invoke it. +This behavior was not tested or documented. Version 1.3.0 quietly changed this behavior, in that `nil` values skipped the coercion. Version 1.5.3 fixes and documents this as follows: + +```ruby +class Api < Grape::API + params do + optional :value, type: Integer, coerce_with: ->(val) { val || 0 } + end + + get 'example' do + params[:my_param] + end + get '/example', params: { value: nil } + # 1.5.2 = nil + # 1.5.3 = 0 + get '/example', params: {} + # 1.5.2 = nil + # 1.5.3 = nil +end +``` +See [#2164](https://github.com/ruby-grape/grape/pull/2164) for more information. + +### Upgrading to >= 1.5.1 + +#### Dependent params + +If you use [dependent params](https://github.com/ruby-grape/grape#dependent-parameters) with +`Grape::Extensions::Hash::ParamBuilder`, make sure a parameter to be dependent on is set as a Symbol. +If a String is given, a parameter that other parameters depend on won't be found even if it is present. + +_Correct_: +```ruby +given :matrix do + # dependent params +end +``` + +_Wrong_: +```ruby +given 'matrix' do + # dependent params +end +``` + +### Upgrading to >= 1.5.0 + +Prior to 1.3.3, the `declared` helper would always return the complete params structure if `include_missing=true` was set. In 1.3.3 a regression was introduced such that a missing Hash with or without nested parameters would always resolve to `{}`. + +In 1.5.0 this behavior is reverted, so the whole params structure will always be available via `declared`, regardless of whether any params are passed. + +The following rules now apply to the `declared` helper when params are missing and `include_missing=true`: + +* Hash params with children will resolve to a Hash with keys for each declared child. +* Hash params with no children will resolve to `{}`. +* Set params will resolve to `Set.new`. +* Array params will resolve to `[]`. +* All other params will resolve to `nil`. + +#### Example + +```ruby +class Api < Grape::API + params do + optional :outer, type: Hash do + optional :inner, type: Hash do + optional :value, type: String + end + end + end + get 'example' do + declared(params, include_missing: true) + end +end +``` + +``` +get '/example' +# 1.3.3 = {} +# 1.5.0 = {outer: {inner: {value:null}}} +``` + +For more information see [#2103](https://github.com/ruby-grape/grape/pull/2103). + ### Upgrading to >= 1.4.0 #### Reworking stream and file and un-deprecating stream like-objects @@ -28,17 +473,17 @@ class API < Grape::API end ``` -Or use `stream` to stream other kinds of content. In the following example a streamer class +Or use `stream` to stream other kinds of content. In the following example a streamer class streams paginated data from a database. ```ruby -class MyObject +class MyObject attr_accessor :result def initialize(query) @result = query end - + def each yield '[' # Do paginated DB fetches and return each page formatted @@ -47,7 +492,7 @@ class MyObject yield process_records(records, first) first = false end - yield ']' + yield ']' end def process_records(records, first) @@ -69,13 +514,13 @@ end #### Nil values for structures -Nil values always been a special case when dealing with types especially with the following structures: +Nil values have always been a special case when dealing with types, especially with the following structures: - Array - Hash - Set -The behavior for these structures has change through out the latest releases. For example: +The behavior for these structures has changed throughout the latest releases. For example: ```ruby class Api < Grape::API @@ -128,6 +573,8 @@ end ### Upgrading to >= 1.3.0 +You will need to upgrade to this version if you depend on `rack >= 2.1.0`. + #### Ruby After adding dry-types, Ruby 2.4 or newer is required. @@ -294,8 +741,7 @@ end ##### `name` (and other caveats) of the mounted API -After the patch, the mounted API is no longer a Named class inheriting from `Grape::API`, it is an anonymous class -which inherit from `Grape::API::Instance`. +After the patch, the mounted API is no longer a Named class inheriting from `Grape::API`, it is an anonymous class which inherit from `Grape::API::Instance`. What this means in practice, is: @@ -675,8 +1121,7 @@ See [#1114](https://github.com/ruby-grape/grape/pull/1114) for more information. #### Bypasses formatters when status code indicates no content -To be consistent with rack and it's handling of standard responses associated with no content, both default and custom formatters will now -be bypassed when processing responses for status codes defined [by rack](https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L567) +To be consistent with rack and it's handling of standard responses associated with no content, both default and custom formatters will now be bypassed when processing responses for status codes defined [by rack](https://github.com/rack/rack/blob/master/lib/rack/utils.rb#L567) See [#1190](https://github.com/ruby-grape/grape/pull/1190) for more information. @@ -1117,8 +1562,7 @@ As replacement can be used * `Grape::Middleware::Auth::Digest` => [`Rack::Auth::Digest::MD5`](https://github.com/rack/rack/blob/master/lib/rack/auth/digest/md5.rb) * `Grape::Middleware::Auth::OAuth2` => [warden-oauth2](https://github.com/opperator/warden-oauth2) or [rack-oauth2](https://github.com/nov/rack-oauth2) -If this is not possible you can extract the middleware files from [grape v0.7.0](https://github.com/ruby-grape/grape/tree/v0.7.0/lib/grape/middleware/auth) -and host these files within your application +If this is not possible you can extract the middleware files from [grape v0.7.0](https://github.com/ruby-grape/grape/tree/v0.7.0/lib/grape/middleware/auth) and host these files within your application See [#703](https://github.com/ruby-grape/Grape/pull/703) for more information. diff --git a/benchmark/compile_many_routes.rb b/benchmark/compile_many_routes.rb index 9fa858cf3..1b273302f 100644 --- a/benchmark/compile_many_routes.rb +++ b/benchmark/compile_many_routes.rb @@ -3,9 +3,6 @@ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) require 'grape' require 'benchmark/ips' -require 'grape/eager_load' - -Grape.eager_load! class API < Grape::API prefix :api diff --git a/benchmark/large_model.rb b/benchmark/large_model.rb new file mode 100644 index 000000000..9eaa1a528 --- /dev/null +++ b/benchmark/large_model.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +# gem 'grape', '=1.0.1' + +require 'grape' +require 'ruby-prof' +require 'hashie' + +class API < Grape::API + # include Grape::Extensions::Hash::ParamBuilder + # include Grape::Extensions::Hashie::Mash::ParamBuilder + + rescue_from do |e| + warn "\n\n#{e.class} (#{e.message}):\n #{e.backtrace.join("\n ")}\n\n" + end + + prefix :api + version 'v1', using: :path + content_type :json, 'application/json; charset=UTF-8' + default_format :json + + def self.vrp_request_timewindow(this) + this.optional(:id, types: String) + this.optional(:start, types: [String, Float, Integer]) + this.optional(:end, types: [String, Float, Integer]) + this.optional(:day_index, type: Integer, values: 0..6) + this.at_least_one_of :start, :end, :day_index + end + + def self.vrp_request_indice_range(this) + this.optional(:start, type: Integer) + this.optional(:end, type: Integer) + end + + def self.vrp_request_point(this) + this.requires(:id, type: String, allow_blank: false) + this.optional(:location, type: Hash, allow_blank: false) do + requires(:lat, type: Float, allow_blank: false) + requires(:lon, type: Float, allow_blank: false) + end + end + + def self.vrp_request_unit(this) + this.requires(:id, type: String, allow_blank: false) + this.optional(:label, type: String) + this.optional(:counting, type: Boolean) + end + + def self.vrp_request_activity(this) + this.optional(:duration, types: [String, Float, Integer]) + this.optional(:additional_value, type: Integer) + this.optional(:setup_duration, types: [String, Float, Integer]) + this.optional(:late_multiplier, type: Float) + this.optional(:timewindow_start_day_shift_number, documentation: { hidden: true }, type: Integer) + this.requires(:point_id, type: String, allow_blank: false) + this.optional(:timewindows, type: Array) do + API.vrp_request_timewindow(self) + end + end + + def self.vrp_request_quantity(this) + this.optional(:id, type: String) + this.requires(:unit_id, type: String, allow_blank: false) + this.optional(:value, type: Float) + end + + def self.vrp_request_capacity(this) + this.optional(:id, type: String) + this.requires(:unit_id, type: String, allow_blank: false) + this.requires(:limit, type: Float, allow_blank: false) + this.optional(:initial, type: Float) + this.optional(:overload_multiplier, type: Float) + end + + def self.vrp_request_vehicle(this) + this.requires(:id, type: String, allow_blank: false) + this.optional(:cost_fixed, type: Float) + this.optional(:cost_distance_multiplier, type: Float) + this.optional(:cost_time_multiplier, type: Float) + + this.optional :router_dimension, type: String, values: %w[time distance] + this.optional(:skills, type: Array[Array[String]], coerce_with: ->(val) { val.is_a?(String) ? [val.split(',').map(&:strip)] : val }) + + this.optional(:unavailable_work_day_indices, type: Array[Integer]) + + this.optional(:free_approach, type: Boolean) + this.optional(:free_return, type: Boolean) + + this.optional(:start_point_id, type: String) + this.optional(:end_point_id, type: String) + this.optional(:capacities, type: Array) do + API.vrp_request_capacity(self) + end + + this.optional(:sequence_timewindows, type: Array) do + API.vrp_request_timewindow(self) + end + end + + def self.vrp_request_service(this) + this.requires(:id, type: String, allow_blank: false) + this.optional(:priority, type: Integer, values: 0..8) + this.optional(:exclusion_cost, type: Integer) + + this.optional(:visits_number, type: Integer, coerce_with: ->(val) { val.to_i.positive? && val.to_i }, default: 1, allow_blank: false) + + this.optional(:unavailable_visit_indices, type: Array[Integer]) + this.optional(:unavailable_visit_day_indices, type: Array[Integer]) + + this.optional(:minimum_lapse, type: Float) + this.optional(:maximum_lapse, type: Float) + + this.optional(:sticky_vehicle_ids, type: Array[String]) + this.optional(:skills, type: Array[String]) + + this.optional(:type, type: Symbol) + this.optional(:activity, type: Hash) do + API.vrp_request_activity(self) + end + this.optional(:quantities, type: Array) do + API.vrp_request_quantity(self) + end + end + + def self.vrp_request_configuration(this) + this.optional(:preprocessing, type: Hash) do + API.vrp_request_preprocessing(self) + end + this.optional(:resolution, type: Hash) do + API.vrp_request_resolution(self) + end + this.optional(:restitution, type: Hash) do + API.vrp_request_restitution(self) + end + this.optional(:schedule, type: Hash) do + API.vrp_request_schedule(self) + end + end + + def self.vrp_request_partition(this) + this.requires(:method, type: String, values: %w[hierarchical_tree balanced_kmeans]) + this.optional(:metric, type: Symbol) + this.optional(:entity, type: Symbol, values: %i[vehicle work_day], coerce_with: lambda(&:to_sym)) + this.optional(:threshold, type: Integer) + end + + def self.vrp_request_preprocessing(this) + this.optional(:max_split_size, type: Integer) + this.optional(:partition_method, type: String, documentation: { hidden: true }) + this.optional(:partition_metric, type: Symbol, documentation: { hidden: true }) + this.optional(:kmeans_centroids, type: Array[Integer]) + this.optional(:cluster_threshold, type: Float) + this.optional(:force_cluster, type: Boolean) + this.optional(:prefer_short_segment, type: Boolean) + this.optional(:neighbourhood_size, type: Integer) + this.optional(:partitions, type: Array) do + API.vrp_request_partition(self) + end + this.optional(:first_solution_strategy, type: Array[String]) + end + + def self.vrp_request_resolution(this) + this.optional(:duration, type: Integer, allow_blank: false) + this.optional(:iterations, type: Integer, allow_blank: false) + this.optional(:iterations_without_improvment, type: Integer, allow_blank: false) + this.optional(:stable_iterations, type: Integer, allow_blank: false) + this.optional(:stable_coefficient, type: Float, allow_blank: false) + this.optional(:initial_time_out, type: Integer, allow_blank: false, documentation: { hidden: true }) + this.optional(:minimum_duration, type: Integer, allow_blank: false) + this.optional(:time_out_multiplier, type: Integer) + this.optional(:vehicle_limit, type: Integer) + this.optional(:solver_parameter, type: Integer, documentation: { hidden: true }) + this.optional(:solver, type: Boolean, default: true) + this.optional(:same_point_day, type: Boolean) + this.optional(:allow_partial_assignment, type: Boolean, default: true) + this.optional(:split_number, type: Integer) + this.optional(:evaluate_only, type: Boolean) + this.optional(:several_solutions, type: Integer, allow_blank: false, default: 1) + this.optional(:batch_heuristic, type: Boolean, default: false) + this.optional(:variation_ratio, type: Integer) + this.optional(:repetition, type: Integer, documentation: { hidden: true }) + this.at_least_one_of :duration, :iterations, :iterations_without_improvment, :stable_iterations, :stable_coefficient, :initial_time_out, :minimum_duration + this.mutually_exclusive :initial_time_out, :minimum_duration + end + + def self.vrp_request_restitution(this) + this.optional(:geometry, type: Boolean) + this.optional(:geometry_polyline, type: Boolean) + this.optional(:intermediate_solutions, type: Boolean) + this.optional(:csv, type: Boolean) + this.optional(:allow_empty_result, type: Boolean) + end + + def self.vrp_request_schedule(this) + this.optional(:range_indices, type: Hash) do + API.vrp_request_indice_range(self) + end + this.optional(:unavailable_indices, type: Array[Integer]) + end + + params do + optional(:vrp, type: Hash, documentation: { param_type: 'body' }) do + optional(:name, type: String) + + optional(:points, type: Array) do + API.vrp_request_point(self) + end + + optional(:units, type: Array) do + API.vrp_request_unit(self) + end + + requires(:vehicles, type: Array) do + API.vrp_request_vehicle(self) + end + + optional(:services, type: Array, allow_blank: false) do + API.vrp_request_service(self) + end + + optional(:configuration, type: Hash) do + API.vrp_request_configuration(self) + end + end + end + post '/' do + { + skills_v1: params[:vrp][:vehicles].first[:skills], + skills_v2: params[:vrp][:vehicles].last[:skills] + } + end +end +puts Grape::VERSION + +options = { + method: Rack::POST, + params: JSON.parse(File.read('benchmark/resource/vrp_example.json')) +} + +env = Rack::MockRequest.env_for('/api/v1', options) + +start = Time.now +result = RubyProf.profile do + response = API.call env + puts response.last +end +puts Time.now - start +printer = RubyProf::FlatPrinter.new(result) +File.open('test_prof.out', 'w+') { |f| printer.print(f, {}) } diff --git a/benchmark/nested_params.rb b/benchmark/nested_params.rb index 2523939b8..35916f920 100644 --- a/benchmark/nested_params.rb +++ b/benchmark/nested_params.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) require 'grape' require 'benchmark/ips' @@ -20,7 +21,7 @@ class API < Grape::API end options = { - method: 'POST', + method: Rack::POST, params: { address: { street: 'Alexis Pl.', diff --git a/benchmark/remounting.rb b/benchmark/remounting.rb new file mode 100644 index 000000000..8b3de34fd --- /dev/null +++ b/benchmark/remounting.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) +require 'grape' +require 'benchmark/memory' + +class VotingApi < Grape::API + logger Logger.new($stdout) + + helpers do + def logger + VotingApi.logger + end + end + + namespace 'votes' do + get do + logger + end + end +end + +class PostApi < Grape::API + mount VotingApi +end + +class CommentAPI < Grape::API + mount VotingApi +end + +env = Rack::MockRequest.env_for('/votes', method: Rack::GET) + +Benchmark.memory do |api| + calls = 1000 + + api.report('using Array') do + VotingApi.instance_variable_set(:@setup, []) + calls.times { PostApi.call(env) } + puts " setup size: #{VotingApi.instance_variable_get(:@setup).size}" + end + + api.report('using Set') do + VotingApi.instance_variable_set(:@setup, Set.new) + calls.times { PostApi.call(env) } + puts " setup size: #{VotingApi.instance_variable_get(:@setup).size}" + end + + api.compare! +end diff --git a/benchmark/resource/vrp_example.json b/benchmark/resource/vrp_example.json new file mode 100644 index 000000000..d9c9cd5bc --- /dev/null +++ b/benchmark/resource/vrp_example.json @@ -0,0 +1 @@ +{"vrp":{"points":[{"id":"1002100","location":{"lat":48.865,"lon":2.3054}},{"id":"1103548","location":{"lat":48.8711,"lon":2.3079}},{"id":"1142617","location":{"lat":48.8756,"lon":2.302}},{"id":"1147052","location":{"lat":48.8758,"lon":2.3074}},{"id":"1104396","location":{"lat":48.8776,"lon":2.3056}},{"id":"1139292","location":{"lat":48.8767,"lon":2.3032}},{"id":"1139149","location":{"lat":48.8767,"lon":2.3073}},{"id":"1118656","location":{"lat":48.8732,"lon":2.3049}},{"id":"1123712","location":{"lat":48.8755,"lon":2.3023}},{"id":"1120539","location":{"lat":48.8739,"lon":2.303}},{"id":"1109631","location":{"lat":48.8774,"lon":2.3047}},{"id":"1139151","location":{"lat":48.8767,"lon":2.3071}},{"id":"1005088","location":{"lat":48.8714,"lon":2.307}},{"id":"1054022","location":{"lat":48.8735,"lon":2.3095}},{"id":"1052132","location":{"lat":48.8733,"lon":2.3058}},{"id":"1080067","location":{"lat":48.8755,"lon":2.3024}},{"id":"1080537","location":{"lat":48.8732,"lon":2.3057}},{"id":"1001821","location":{"lat":48.8721,"lon":2.3043}},{"id":"1033652","location":{"lat":48.8758,"lon":2.3031}},{"id":"1127811","location":{"lat":48.8768,"lon":2.3091}},{"id":"1031446","location":{"lat":48.8723,"lon":2.3033}},{"id":"1004332","location":{"lat":48.8733,"lon":2.3056}},{"id":"1030348","location":{"lat":48.875,"lon":2.3051}},{"id":"1062118","location":{"lat":48.873,"lon":2.305}},{"id":"1035112","location":{"lat":48.8755,"lon":2.3023}},{"id":"1001140","location":{"lat":48.8776,"lon":2.3038}},{"id":"1144968","location":{"lat":48.8749,"lon":2.304}},{"id":"1136835","location":{"lat":48.8732,"lon":2.3051}},{"id":"1133790","location":{"lat":48.879,"lon":2.3043}},{"id":"1133878","location":{"lat":48.8785,"lon":2.3039}},{"id":"1007882","location":{"lat":48.8738,"lon":2.2965}},{"id":"1020596","location":{"lat":48.8664,"lon":2.31}},{"id":"1064282","location":{"lat":48.8731,"lon":2.3072}},{"id":"1134687","location":{"lat":48.8759,"lon":2.3077}},{"id":"1135600","location":{"lat":48.8768,"lon":2.3092}},{"id":"1133576","location":{"lat":48.8768,"lon":2.3091}},{"id":"1138821","location":{"lat":48.8749,"lon":2.3035}},{"id":"1066596","location":{"lat":48.8722,"lon":2.2967}},{"id":"1080091","location":{"lat":48.8787,"lon":2.3051}},{"id":"1094392","location":{"lat":48.8732,"lon":2.3131}},{"id":"1071805","location":{"lat":48.8755,"lon":2.3022}},{"id":"1064291","location":{"lat":48.8731,"lon":2.3072}},{"id":"1137046","location":{"lat":48.8732,"lon":2.3051}},{"id":"1131694","location":{"lat":48.8744,"lon":2.2984}},{"id":"1005035","location":{"lat":48.8786,"lon":2.3131}},{"id":"1004005","location":{"lat":48.8733,"lon":2.3062}},{"id":"1041519","location":{"lat":48.8755,"lon":2.3022}},{"id":"1148428","location":{"lat":0.0,"lon":0.0}},{"id":"1119178","location":{"lat":48.8726,"lon":2.304}},{"id":"1030515","location":{"lat":48.8789,"lon":2.303}},{"id":"1130633","location":{"lat":48.8755,"lon":2.3023}},{"id":"1132792","location":{"lat":48.8744,"lon":2.2984}},{"id":"1124356","location":{"lat":48.8753,"lon":2.3047}},{"id":"1121089","location":{"lat":48.8769,"lon":2.3074}},{"id":"1102925","location":{"lat":48.8732,"lon":2.3131}},{"id":"1102928","location":{"lat":48.8732,"lon":2.3131}},{"id":"1105871","location":{"lat":48.872,"lon":2.3039}},{"id":"1116088","location":{"lat":48.8768,"lon":2.3091}},{"id":"1109290","location":{"lat":48.8747,"lon":2.2982}},{"id":"1131649","location":{"lat":48.8775,"lon":2.2997}},{"id":"1136697","location":{"lat":48.8732,"lon":2.3051}},{"id":"1030517","location":{"lat":48.8751,"lon":2.3064}},{"id":"1132871","location":{"lat":48.8732,"lon":2.3051}},{"id":"1148306","location":{"lat":0.0,"lon":0.0}},{"id":"1126467","location":{"lat":48.8768,"lon":2.3091}},{"id":"1130723","location":{"lat":48.8768,"lon":2.3006}},{"id":"1099009","location":{"lat":48.874,"lon":2.2984}},{"id":"1095726","location":{"lat":48.8777,"lon":2.2994}},{"id":"1005056","location":{"lat":48.8776,"lon":2.3038}},{"id":"1122952","location":{"lat":48.8738,"lon":2.3005}},{"id":"1126324","location":{"lat":48.8768,"lon":2.3091}},{"id":"1124513","location":{"lat":48.8732,"lon":2.3051}},{"id":"1124103","location":{"lat":48.873,"lon":2.3047}},{"id":"1131394","location":{"lat":48.8747,"lon":2.3239}},{"id":"1133951","location":{"lat":48.8704,"lon":2.3211}},{"id":"1137715","location":{"lat":48.8698,"lon":2.3182}},{"id":"1132589","location":{"lat":48.8739,"lon":2.3214}},{"id":"1145751","location":{"lat":48.8715,"lon":2.3236}},{"id":"1070749","location":{"lat":48.8712,"lon":2.3194}},{"id":"1070735","location":{"lat":48.8703,"lon":2.3176}},{"id":"1002504","location":{"lat":48.8696,"lon":2.3188}},{"id":"1007287","location":{"lat":48.8707,"lon":2.3199}},{"id":"1005919","location":{"lat":48.8698,"lon":2.3178}},{"id":"1143914","location":{"lat":48.8693,"lon":2.3201}},{"id":"1144594","location":{"lat":48.8764,"lon":2.3083}},{"id":"1127546","location":{"lat":48.8692,"lon":2.3209}},{"id":"1123348","location":{"lat":48.8742,"lon":2.3171}},{"id":"1103574","location":{"lat":48.8711,"lon":2.3185}},{"id":"1087334","location":{"lat":48.8724,"lon":2.3183}},{"id":"1088315","location":{"lat":48.8762,"lon":2.3135}},{"id":"1054230","location":{"lat":48.8697,"lon":2.3198}},{"id":"1058540","location":{"lat":48.8701,"lon":2.3209}},{"id":"1106440","location":{"lat":48.87,"lon":2.3185}},{"id":"1120609","location":{"lat":48.8729,"lon":2.3228}},{"id":"1119750","location":{"lat":48.8693,"lon":2.3195}},{"id":"1107065","location":{"lat":48.8708,"lon":2.3202}},{"id":"1096970","location":{"lat":48.8733,"lon":2.3193}},{"id":"1124357","location":{"lat":48.8716,"lon":2.3216}},{"id":"1130453","location":{"lat":48.8763,"lon":2.3139}},{"id":"1121283","location":{"lat":48.8733,"lon":2.3213}},{"id":"1143992","location":{"lat":48.8713,"lon":2.3226}},{"id":"1020782","location":{"lat":48.8717,"lon":2.3198}},{"id":"1109136","location":{"lat":48.8732,"lon":2.3214}},{"id":"1107406","location":{"lat":48.87,"lon":2.3189}},{"id":"1001454","location":{"lat":48.8717,"lon":2.322}},{"id":"1031405","location":{"lat":48.8733,"lon":2.3181}},{"id":"1099019","location":{"lat":48.8712,"lon":2.3184}},{"id":"1040631","location":{"lat":48.8722,"lon":2.3231}},{"id":"1030463","location":{"lat":48.8725,"lon":2.3218}},{"id":"1033191","location":{"lat":48.8736,"lon":2.3213}},{"id":"1133959","location":{"lat":48.873,"lon":2.3163}},{"id":"1004770","location":{"lat":48.8788,"lon":2.3171}},{"id":"1129651","location":{"lat":48.8713,"lon":2.3226}},{"id":"1121101","location":{"lat":48.8701,"lon":2.3183}},{"id":"1119751","location":{"lat":48.8703,"lon":2.3212}},{"id":"1137030","location":{"lat":48.8729,"lon":2.3223}},{"id":"1134263","location":{"lat":48.8764,"lon":2.3142}},{"id":"1133530","location":{"lat":48.873,"lon":2.3176}},{"id":"1142237","location":{"lat":48.8713,"lon":2.3226}},{"id":"1030487","location":{"lat":48.8701,"lon":2.3191}},{"id":"1004647","location":{"lat":48.874,"lon":2.3186}},{"id":"1004716","location":{"lat":48.8737,"lon":2.3172}},{"id":"1144936","location":{"lat":48.8772,"lon":2.3165}},{"id":"1134666","location":{"lat":48.874,"lon":2.3184}},{"id":"1006725","location":{"lat":48.8736,"lon":2.3158}},{"id":"1092502","location":{"lat":48.8754,"lon":2.323}},{"id":"1008001","location":{"lat":48.8749,"lon":2.3158}},{"id":"1144493","location":{"lat":48.873,"lon":2.3124}},{"id":"1147114","location":{"lat":48.8738,"lon":2.3165}},{"id":"1147721","location":{"lat":0.0,"lon":0.0}},{"id":"1003152","location":{"lat":48.8763,"lon":2.3205}},{"id":"1110450","location":{"lat":48.8735,"lon":2.3142}},{"id":"1070260","location":{"lat":48.8742,"lon":2.3206}},{"id":"1132451","location":{"lat":48.8739,"lon":2.3193}},{"id":"1122595","location":{"lat":48.8743,"lon":2.3212}},{"id":"1134348","location":{"lat":48.8749,"lon":2.3211}},{"id":"1127201","location":{"lat":48.8732,"lon":2.3131}},{"id":"1138580","location":{"lat":48.8751,"lon":2.3211}},{"id":"1143039","location":{"lat":48.8731,"lon":2.3135}},{"id":"1132224","location":{"lat":48.8746,"lon":2.3226}},{"id":"1095177","location":{"lat":48.877,"lon":2.3175}},{"id":"1111407","location":{"lat":48.8745,"lon":2.3219}},{"id":"1117925","location":{"lat":48.8739,"lon":2.3178}},{"id":"1135294","location":{"lat":48.8737,"lon":2.3138}},{"id":"1031534","location":{"lat":48.8735,"lon":2.3143}},{"id":"1047944","location":{"lat":48.8739,"lon":2.3195}},{"id":"1050281","location":{"lat":48.873,"lon":2.3157}},{"id":"1054024","location":{"lat":48.8754,"lon":2.3236}},{"id":"1040973","location":{"lat":48.8765,"lon":2.3173}},{"id":"1063338","location":{"lat":48.8752,"lon":2.3171}},{"id":"1031918","location":{"lat":48.8739,"lon":2.3178}},{"id":"1145151","location":{"lat":48.8739,"lon":2.3193}},{"id":"1054036","location":{"lat":48.8748,"lon":2.3215}},{"id":"1004708","location":{"lat":48.875,"lon":2.3203}},{"id":"1002561","location":{"lat":48.8744,"lon":2.3174}},{"id":"1005880","location":{"lat":48.8738,"lon":2.3161}},{"id":"1144485","location":{"lat":48.8736,"lon":2.3139}},{"id":"1116199","location":{"lat":48.8737,"lon":2.3142}},{"id":"1123435","location":{"lat":48.8738,"lon":2.318}},{"id":"1124213","location":{"lat":48.8743,"lon":2.3182}},{"id":"startvehicule1","location":{"lat":48.78,"lon":2.43}},{"id":"startvehicule2","location":{"lat":48.78,"lon":2.43}},{"id":"endvehicule1","location":{"lat":48.78,"lon":2.43}},{"id":"endvehicule2","location":{"lat":48.78,"lon":2.43}}],"units":[{"id":"kg","label":"kg"},{"id":"l","label":"l"},{"id":"qte","label":"qte"}],"services":[{"id":"1002100_EMP_ 28_1FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":3,"minimum_lapse":120.0,"activity":{"point_id":"1002100","duration":120,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":30600,"end":45000,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1147052_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":9.08},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1147052","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1104396_SAV_ 84_4FA","quantities":[{"unit_id":"kg","value":5.5},{"unit_id":"l","value":5.0},{"unit_id":"qte","value":5.0}],"visits_number":1,"minimum_lapse":20.0,"activity":{"point_id":"1104396","duration":20,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1147052_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":9.08},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1147052","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1139292_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139149_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1139149","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1104396_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":5.5},{"unit_id":"l","value":5.0},{"unit_id":"qte","value":5.0}],"visits_number":1,"minimum_lapse":20.0,"activity":{"point_id":"1104396","duration":20,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1104396_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":5.5},{"unit_id":"l","value":5.0},{"unit_id":"qte","value":5.0}],"visits_number":1,"minimum_lapse":20.0,"activity":{"point_id":"1104396","duration":20,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1120539_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1120539","duration":8,"setup_duration":120,"timewindows":[{"start":34200,"end":64800,"day_index":0},{"start":34200,"end":64800,"day_index":1},{"start":34200,"end":64800,"day_index":2},{"start":34200,"end":64800,"day_index":3},{"start":34200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1120539_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1120539","duration":8,"setup_duration":120,"timewindows":[{"start":34200,"end":64800,"day_index":0},{"start":34200,"end":64800,"day_index":1},{"start":34200,"end":64800,"day_index":2},{"start":34200,"end":64800,"day_index":3},{"start":34200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1120539_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1120539","duration":8,"setup_duration":120,"timewindows":[{"start":34200,"end":64800,"day_index":0},{"start":34200,"end":64800,"day_index":1},{"start":34200,"end":64800,"day_index":2},{"start":34200,"end":64800,"day_index":3},{"start":34200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1109631_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1109631_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1109631_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1118656_PH _ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139151_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1139151_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1139151_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1005088_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1052132_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1052132_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1080537_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080537_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080537_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1001821_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1001821_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1033652_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":21.0},{"unit_id":"l","value":256.0},{"unit_id":"qte","value":20.0}],"visits_number":3,"minimum_lapse":160.0,"activity":{"point_id":"1033652","duration":160,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":30600,"end":45000,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1052132_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1031446_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":4.89},{"unit_id":"l","value":19.55},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1031446","duration":100,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005088_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005088_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1004332_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1033652_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":21.0},{"unit_id":"l","value":256.0},{"unit_id":"qte","value":20.0}],"visits_number":3,"minimum_lapse":160.0,"activity":{"point_id":"1033652","duration":160,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":30600,"end":45000,"day_index":4}]},"type":"service"},{"id":"1001821_ASC_ 84_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1139149_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1139149","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1062118_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1062118","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1062118_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1062118","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1062118_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1062118","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1062118_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1062118","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1035112_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1035112","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1035112_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1035112","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1001140_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":104.0,"activity":{"point_id":"1001140","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":68400,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":68400,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":68400,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":68400,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1035112_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1035112","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1144968_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.852},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1144968","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144968_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.852},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1144968","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144968_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.852},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1144968","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136835_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.44},{"unit_id":"l","value":8.4},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1136835","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133790_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133790","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133790_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133790","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133790_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133790","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133878_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133878","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133878_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133878","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133878_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":10.4},{"unit_id":"l","value":49.248},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1133878","duration":64,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1007882_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1020596_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.2},{"unit_id":"l","value":15.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1020596","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064282_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":52.0,"activity":{"point_id":"1064282","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1064282_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":52.0,"activity":{"point_id":"1064282","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1134687_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1134687","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1135600_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":49.35},{"unit_id":"l","value":601.6},{"unit_id":"qte","value":47.0}],"visits_number":3,"minimum_lapse":376.0,"activity":{"point_id":"1135600","duration":376,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1135600_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":49.35},{"unit_id":"l","value":601.6},{"unit_id":"qte","value":47.0}],"visits_number":3,"minimum_lapse":376.0,"activity":{"point_id":"1135600","duration":376,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133576_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":59.52},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":192.0}],"visits_number":3,"minimum_lapse":768.0,"activity":{"point_id":"1133576","duration":768,"setup_duration":120,"timewindows":[{"start":23400,"end":34200,"day_index":0},{"start":23400,"end":34200,"day_index":1},{"start":23400,"end":34200,"day_index":2},{"start":23400,"end":34200,"day_index":3},{"start":23400,"end":34200,"day_index":4}]},"type":"service"},{"id":"1133576_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":59.52},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":192.0}],"visits_number":3,"minimum_lapse":768.0,"activity":{"point_id":"1133576","duration":768,"setup_duration":120,"timewindows":[{"start":23400,"end":34200,"day_index":0},{"start":23400,"end":34200,"day_index":1},{"start":23400,"end":34200,"day_index":2},{"start":23400,"end":34200,"day_index":3},{"start":23400,"end":34200,"day_index":4}]},"type":"service"},{"id":"1133576_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":59.52},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":192.0}],"visits_number":3,"minimum_lapse":768.0,"activity":{"point_id":"1133576","duration":768,"setup_duration":120,"timewindows":[{"start":23400,"end":34200,"day_index":0},{"start":23400,"end":34200,"day_index":1},{"start":23400,"end":34200,"day_index":2},{"start":23400,"end":34200,"day_index":3},{"start":23400,"end":34200,"day_index":4}]},"type":"service"},{"id":"1138821_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":26.25},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":125.0}],"visits_number":3,"minimum_lapse":500.0,"activity":{"point_id":"1138821","duration":500,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138821_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":26.25},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":125.0}],"visits_number":3,"minimum_lapse":500.0,"activity":{"point_id":"1138821","duration":500,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138821_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":26.25},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":125.0}],"visits_number":3,"minimum_lapse":500.0,"activity":{"point_id":"1138821","duration":500,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134687_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1134687","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1139149_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1139149","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1134687_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1134687","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1066596_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064282_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":52.0,"activity":{"point_id":"1064282","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1080091_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":64.74},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":204.0}],"visits_number":3,"minimum_lapse":816.0,"activity":{"point_id":"1080091","duration":816,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080091_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":64.74},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":204.0}],"visits_number":3,"minimum_lapse":816.0,"activity":{"point_id":"1080091","duration":816,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1094392_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1094392","duration":90,"setup_duration":120,"timewindows":[{"start":25200,"end":45000,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":25200,"end":45000,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":25200,"end":45000,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":25200,"end":45000,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":25200,"end":45000,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1080091_CLI_ 84_4FA","quantities":[{"unit_id":"kg","value":64.74},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":204.0}],"visits_number":3,"minimum_lapse":816.0,"activity":{"point_id":"1080091","duration":816,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1071805_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":130.0,"activity":{"point_id":"1071805","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":70200,"day_index":0},{"start":28800,"end":70200,"day_index":1},{"start":28800,"end":70200,"day_index":2},{"start":28800,"end":70200,"day_index":3},{"start":28800,"end":70200,"day_index":4}]},"type":"service"},{"id":"1064291_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1134687_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1134687","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1136835_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":4.44},{"unit_id":"l","value":8.4},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1136835","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1001821_CLI_ 84_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064291_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1066596_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064291_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1066596_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064291_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1137046_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137046_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131694_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1137046_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1071805_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":130.0,"activity":{"point_id":"1071805","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":70200,"day_index":0},{"start":28800,"end":70200,"day_index":1},{"start":28800,"end":70200,"day_index":2},{"start":28800,"end":70200,"day_index":3},{"start":28800,"end":70200,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1066596_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005035_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005035_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004005_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004005_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004005_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004005_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1041519_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":2.59},{"unit_id":"l","value":2.625},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1041519","duration":28,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1064282_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":52.0,"activity":{"point_id":"1064282","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1131694_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1131694_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148428_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148428_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1139292_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1118656_PH _ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004005_TAP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119178_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1123712_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119178_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_DIF_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1118656_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1001821_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005035_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1127811_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1130633_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1130633_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130633_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130633_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132792_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":16.6},{"unit_id":"l","value":66.0},{"unit_id":"qte","value":5.0}],"visits_number":6,"minimum_lapse":500.0,"activity":{"point_id":"1132792","duration":500,"setup_duration":120,"timewindows":[{"start":21600,"end":61200,"day_index":0},{"start":21600,"end":61200,"day_index":1},{"start":21600,"end":61200,"day_index":2},{"start":21600,"end":61200,"day_index":3},{"start":21600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1130633_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124356_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1124356","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1121089_EMP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1102925_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102925","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1102928_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102928","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1105871_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1105871_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1105871_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1116088_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1116088_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1105871_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109290_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":50.6},{"unit_id":"l","value":142.6},{"unit_id":"qte","value":23.0}],"visits_number":6,"minimum_lapse":299.0,"activity":{"point_id":"1109290","duration":299,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131649_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":3.36},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":16.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1131649","duration":64,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1131649_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":3.36},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":16.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1131649","duration":64,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1136697_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136697_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030517_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1007882_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1007882_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1007882_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1020596_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.2},{"unit_id":"l","value":15.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1020596","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030515_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030517_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005035_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030517_CLI_ 84_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030517_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136697_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136697_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132871_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1132871","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1148306_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148306_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148306_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1001140_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":104.0,"activity":{"point_id":"1001140","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":68400,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":68400,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":68400,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":68400,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030517_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139292_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136835_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.44},{"unit_id":"l","value":8.4},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1136835","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1126467_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1126467","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":30600,"day_index":0},{"start":21600,"end":30600,"day_index":1},{"start":21600,"end":30600,"day_index":2},{"start":21600,"end":30600,"day_index":3},{"start":21600,"end":30600,"day_index":4}]},"type":"service"},{"id":"1130633_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1102928_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102928","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1130633_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130633_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131649_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":3.36},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":16.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1131649","duration":64,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1132792_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":16.6},{"unit_id":"l","value":66.0},{"unit_id":"qte","value":5.0}],"visits_number":6,"minimum_lapse":500.0,"activity":{"point_id":"1132792","duration":500,"setup_duration":120,"timewindows":[{"start":21600,"end":61200,"day_index":0},{"start":21600,"end":61200,"day_index":1},{"start":21600,"end":61200,"day_index":2},{"start":21600,"end":61200,"day_index":3},{"start":21600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1136697_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136697_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131649_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":3.36},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":16.0}],"visits_number":3,"minimum_lapse":64.0,"activity":{"point_id":"1131649","duration":64,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1130633_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130633_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130633_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1130633","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1116088_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1105871_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109290_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":50.6},{"unit_id":"l","value":142.6},{"unit_id":"qte","value":23.0}],"visits_number":6,"minimum_lapse":299.0,"activity":{"point_id":"1109290","duration":299,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_EMP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124356_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1124356","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1127811_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1136697_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1136697_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":312.0,"activity":{"point_id":"1136697","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132871_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":4.8},{"unit_id":"l","value":94.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1132871","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1066596_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1071805_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":130.0,"activity":{"point_id":"1071805","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":70200,"day_index":0},{"start":28800,"end":70200,"day_index":1},{"start":28800,"end":70200,"day_index":2},{"start":28800,"end":70200,"day_index":3},{"start":28800,"end":70200,"day_index":4}]},"type":"service"},{"id":"1066596_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1066596_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1066596","duration":100,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064291_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1064291_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1064291_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1064291","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1004005_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1064282_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":52.0,"activity":{"point_id":"1064282","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":70200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":70200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":70200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":70200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":70200,"day_index":4}]},"type":"service"},{"id":"1116088_CLI_ 84_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1030517_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1148306_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148306_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148306_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.62},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":22.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1148306","duration":88,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1001140_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":104.0,"activity":{"point_id":"1001140","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":68400,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":68400,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":68400,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":68400,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030517_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030517_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030517_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":1.095},{"unit_id":"l","value":1.53},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1030517","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1041519_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":2.59},{"unit_id":"l","value":2.625},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1041519","duration":28,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1004005_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1116088_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1105871_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1139292_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139151_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1139151_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1139151_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1139151","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1005088_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005088_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005088_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":6.2},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1005088","duration":16,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1139149_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1139149","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":72000,"day_index":0},{"start":32400,"end":72000,"day_index":1},{"start":32400,"end":72000,"day_index":2},{"start":32400,"end":72000,"day_index":3},{"start":32400,"end":72000,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1147052_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":9.08},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1147052","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1147052_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":9.08},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1147052","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1109631_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1109631_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1118656_PH _ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1104396_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":5.5},{"unit_id":"l","value":5.0},{"unit_id":"qte","value":5.0}],"visits_number":1,"minimum_lapse":20.0,"activity":{"point_id":"1104396","duration":20,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1104396_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":5.5},{"unit_id":"l","value":5.0},{"unit_id":"qte","value":5.0}],"visits_number":1,"minimum_lapse":20.0,"activity":{"point_id":"1104396","duration":20,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1004332_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1080537_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1001821_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1001821_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":15.0,"activity":{"point_id":"1001821","duration":15,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1102925_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102925","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1102925_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102925","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1102928_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1102928","duration":90,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":63000,"end":73800,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":63000,"end":73800,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":63000,"end":73800,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":63000,"end":73800,"day_index":3},{"start":21600,"end":32400,"day_index":4},{"start":63000,"end":73800,"day_index":4}]},"type":"service"},{"id":"1105871_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1105871_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080537_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1116088_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":20.29},{"unit_id":"l","value":37.8},{"unit_id":"qte","value":64.0}],"visits_number":3,"minimum_lapse":256.0,"activity":{"point_id":"1116088","duration":256,"setup_duration":120,"timewindows":[{"start":28800,"end":43200,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":28800,"end":43200,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":28800,"end":43200,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":28800,"end":43200,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":28800,"end":43200,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1080537_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":11.55},{"unit_id":"l","value":140.8},{"unit_id":"qte","value":11.0}],"visits_number":3,"minimum_lapse":88.0,"activity":{"point_id":"1080537","duration":88,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1052132_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004332_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1004332_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1031446_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":4.89},{"unit_id":"l","value":19.55},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1031446","duration":100,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1033652_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":21.0},{"unit_id":"l","value":256.0},{"unit_id":"qte","value":20.0}],"visits_number":3,"minimum_lapse":160.0,"activity":{"point_id":"1033652","duration":160,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":30600,"end":45000,"day_index":4}]},"type":"service"},{"id":"1033652_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":21.0},{"unit_id":"l","value":256.0},{"unit_id":"qte","value":20.0}],"visits_number":3,"minimum_lapse":160.0,"activity":{"point_id":"1033652","duration":160,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":30600,"end":45000,"day_index":4}]},"type":"service"},{"id":"1052132_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1052132_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":14.7},{"unit_id":"l","value":179.2},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":112.0,"activity":{"point_id":"1052132","duration":112,"setup_duration":120,"timewindows":[{"start":34200,"end":61200,"day_index":0},{"start":34200,"end":61200,"day_index":1},{"start":34200,"end":61200,"day_index":2},{"start":34200,"end":61200,"day_index":3},{"start":34200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1130723_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":115.2},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1130723","duration":72,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004005_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1007882_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1099009_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099009_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099009_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099009_CLI_168_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099009_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1105871_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":41.8},{"unit_id":"l","value":117.8},{"unit_id":"qte","value":19.0}],"visits_number":6,"minimum_lapse":247.0,"activity":{"point_id":"1105871","duration":247,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109290_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":50.6},{"unit_id":"l","value":142.6},{"unit_id":"qte","value":23.0}],"visits_number":6,"minimum_lapse":299.0,"activity":{"point_id":"1109290","duration":299,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099009_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":160.0,"activity":{"point_id":"1099009","duration":160,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1095726_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1095726","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1095726_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1095726","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1095726_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1095726","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005056_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1005056","duration":26,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1005056_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1005056","duration":26,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1033652_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":21.0},{"unit_id":"l","value":256.0},{"unit_id":"qte","value":20.0}],"visits_number":3,"minimum_lapse":160.0,"activity":{"point_id":"1033652","duration":160,"setup_duration":120,"timewindows":[{"start":30600,"end":45000,"day_index":0},{"start":30600,"end":45000,"day_index":1},{"start":30600,"end":45000,"day_index":2},{"start":30600,"end":45000,"day_index":3},{"start":30600,"end":45000,"day_index":4}]},"type":"service"},{"id":"1054022_SNC_ 7_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1054022","duration":90,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1080067_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1080067_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":33.0},{"unit_id":"l","value":93.0},{"unit_id":"qte","value":15.0}],"visits_number":12,"minimum_lapse":195.0,"activity":{"point_id":"1080067","duration":195,"setup_duration":120,"timewindows":[{"start":21600,"end":46800,"day_index":0},{"start":21600,"end":46800,"day_index":1},{"start":21600,"end":46800,"day_index":2},{"start":21600,"end":46800,"day_index":3},{"start":21600,"end":46800,"day_index":4}]},"type":"service"},{"id":"1095726_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1095726","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1095726_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1095726","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_EMP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121089_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":19.5},{"unit_id":"l","value":92.34},{"unit_id":"qte","value":15.0}],"visits_number":6,"minimum_lapse":120.0,"activity":{"point_id":"1121089","duration":120,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1122952_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":15.4},{"unit_id":"l","value":43.4},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":91.0,"activity":{"point_id":"1122952","duration":91,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1126324_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":45.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1126324","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":36000,"day_index":0},{"start":32400,"end":36000,"day_index":1},{"start":32400,"end":36000,"day_index":2},{"start":32400,"end":36000,"day_index":3},{"start":32400,"end":36000,"day_index":4}]},"type":"service"},{"id":"1126324_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":45.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1126324","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":36000,"day_index":0},{"start":32400,"end":36000,"day_index":1},{"start":32400,"end":36000,"day_index":2},{"start":32400,"end":36000,"day_index":3},{"start":32400,"end":36000,"day_index":4}]},"type":"service"},{"id":"1126324_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":45.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1126324","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":36000,"day_index":0},{"start":32400,"end":36000,"day_index":1},{"start":32400,"end":36000,"day_index":2},{"start":32400,"end":36000,"day_index":3},{"start":32400,"end":36000,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1127811_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1126467_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1126467","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":30600,"day_index":0},{"start":21600,"end":30600,"day_index":1},{"start":21600,"end":30600,"day_index":2},{"start":21600,"end":30600,"day_index":3},{"start":21600,"end":30600,"day_index":4}]},"type":"service"},{"id":"1126467_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1126467","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":30600,"day_index":0},{"start":21600,"end":30600,"day_index":1},{"start":21600,"end":30600,"day_index":2},{"start":21600,"end":30600,"day_index":3},{"start":21600,"end":30600,"day_index":4}]},"type":"service"},{"id":"1130723_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":115.2},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1130723","duration":72,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132792_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":16.6},{"unit_id":"l","value":66.0},{"unit_id":"qte","value":5.0}],"visits_number":6,"minimum_lapse":500.0,"activity":{"point_id":"1132792","duration":500,"setup_duration":120,"timewindows":[{"start":21600,"end":61200,"day_index":0},{"start":21600,"end":61200,"day_index":1},{"start":21600,"end":61200,"day_index":2},{"start":21600,"end":61200,"day_index":3},{"start":21600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1126324_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":9.45},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":45.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1126324","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":36000,"day_index":0},{"start":32400,"end":36000,"day_index":1},{"start":32400,"end":36000,"day_index":2},{"start":32400,"end":36000,"day_index":3},{"start":32400,"end":36000,"day_index":4}]},"type":"service"},{"id":"1030348_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1124513_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":2.94},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1124513","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1122952_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":15.4},{"unit_id":"l","value":43.4},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":91.0,"activity":{"point_id":"1122952","duration":91,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124103_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":3.9},{"unit_id":"l","value":18.468},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1124103","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124103_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":3.9},{"unit_id":"l","value":18.468},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1124103","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1122952_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":15.4},{"unit_id":"l","value":43.4},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":91.0,"activity":{"point_id":"1122952","duration":91,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124356_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1124356","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1124103_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":3.9},{"unit_id":"l","value":18.468},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1124103","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124513_BOB_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":2.94},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1124513","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1124513_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":2.94},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1124513","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1124513_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":2.94},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1124513","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1124513_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":2.94},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1124513","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":68400,"day_index":0},{"start":36000,"end":68400,"day_index":1},{"start":36000,"end":68400,"day_index":2},{"start":36000,"end":68400,"day_index":3},{"start":36000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1004005_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030348_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1031446_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":4.89},{"unit_id":"l","value":19.55},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1031446","duration":100,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1137046_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131694_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1131694_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1137046_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131694_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":11.7},{"unit_id":"l","value":55.404},{"unit_id":"qte","value":9.0}],"visits_number":3,"minimum_lapse":72.0,"activity":{"point_id":"1131694","duration":72,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":52200,"end":61200,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":52200,"end":61200,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":52200,"end":61200,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":52200,"end":61200,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":52200,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127811_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1123712_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1127811_PH _ 7_4FA","quantities":[{"unit_id":"kg","value":29.76},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":96.0}],"visits_number":12,"minimum_lapse":384.0,"activity":{"point_id":"1127811","duration":384,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1137046_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1137046","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005035_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005035_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1007882_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1007882_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":26.4},{"unit_id":"l","value":74.4},{"unit_id":"qte","value":12.0}],"visits_number":6,"minimum_lapse":117.0,"activity":{"point_id":"1007882","duration":117,"setup_duration":120,"timewindows":[{"start":21600,"end":43200,"day_index":0},{"start":21600,"end":43200,"day_index":1},{"start":21600,"end":43200,"day_index":2},{"start":21600,"end":43200,"day_index":3},{"start":21600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1020596_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.2},{"unit_id":"l","value":15.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1020596","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1030515_SNC_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1030515_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1030515","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005035_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":7.64},{"unit_id":"l","value":16.8},{"unit_id":"qte","value":24.0}],"visits_number":3,"minimum_lapse":132.0,"activity":{"point_id":"1005035","duration":132,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004005_TAP_ 28_4FA","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":12.4},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":26.0,"activity":{"point_id":"1004005","duration":26,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123712_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1123712","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119178_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1142617_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_PCP_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1142617_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1139292_SNC_ 14_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139292_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139292_CLI_ 84_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1139292_DIF_ 84_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148428_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031446_ASC_ 84_4FA","quantities":[{"unit_id":"kg","value":4.89},{"unit_id":"l","value":19.55},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1031446","duration":100,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1139292_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004332_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1142617_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1148428_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119178_LPL_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_DIF_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119178_CLI_ 28_4FA","quantities":[{"unit_id":"kg","value":12.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":250.0}],"visits_number":3,"minimum_lapse":3250.0,"activity":{"point_id":"1119178","duration":3250,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1118656_PCP_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1118656_PH _ 14_4FA","quantities":[{"unit_id":"kg","value":14.88},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":48.0}],"visits_number":6,"minimum_lapse":192.0,"activity":{"point_id":"1118656","duration":192,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103548_TAP_ 7_4FA","quantities":[{"unit_id":"kg","value":9.7},{"unit_id":"l","value":37.02},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1103548","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148428_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1142617_SAV_ 14_4FA","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":24.0,"activity":{"point_id":"1142617","duration":24,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1139292_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":1.9},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":180.0,"activity":{"point_id":"1139292","duration":180,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1148428_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":22.1},{"unit_id":"l","value":104.652},{"unit_id":"qte","value":17.0}],"visits_number":3,"minimum_lapse":136.0,"activity":{"point_id":"1148428","duration":136,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031446_TAP_ 14_4FA","quantities":[{"unit_id":"kg","value":4.89},{"unit_id":"l","value":19.55},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":100.0,"activity":{"point_id":"1031446","duration":100,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1131394_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1131394_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1133951_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137715_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137715_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1131394_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1137715_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070735_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1007287_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":15.5},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":50.0}],"visits_number":3,"minimum_lapse":200.0,"activity":{"point_id":"1007287","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1143914_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":8.94},{"unit_id":"l","value":33.9},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1143914","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1127546_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127546_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127546_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1123348_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103574_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.305},{"unit_id":"l","value":14.7},{"unit_id":"qte","value":23.0}],"visits_number":3,"minimum_lapse":92.0,"activity":{"point_id":"1103574","duration":92,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1087334_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1088315_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.8},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1088315","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1088315_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.8},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1088315","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1058540_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1058540","duration":180,"setup_duration":120,"timewindows":[{"start":30600,"end":72000,"day_index":0},{"start":30600,"end":72000,"day_index":1},{"start":30600,"end":72000,"day_index":2},{"start":30600,"end":72000,"day_index":3},{"start":30600,"end":72000,"day_index":4}]},"type":"service"},{"id":"1054230_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1103574_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.305},{"unit_id":"l","value":14.7},{"unit_id":"qte","value":23.0}],"visits_number":3,"minimum_lapse":92.0,"activity":{"point_id":"1103574","duration":92,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1120609_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1123348_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123348_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123348_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119750_EMP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1107065_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1119750_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119750_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1120609_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1120609_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1070749_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1096970_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1124357_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1124357","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1130453_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1121283_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1143992_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1143992_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1020782_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1143992_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1121283_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1109136_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109136_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109136_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1020782_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1001454_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1001454_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1070735_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070735_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031405_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.375},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1031405","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1031405_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.375},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1031405","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1107065_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1096970_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1070749_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1096970_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1040631_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1001454_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1030463_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1033191_ASC_ 84_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":400.0,"activity":{"point_id":"1033191","duration":400,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1040631_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1054230_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1133951_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1133959_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004770_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004770_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002504_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_ASC_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1143914_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":8.94},{"unit_id":"l","value":33.9},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1143914","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1129651_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1129651","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1129651_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1129651","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1121101_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1121101_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1109136_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1109136_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_DIF_ 42_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004770_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1119751_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119750_EMP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1107065_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121101_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1121101_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1119750_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119750_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119751_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119751_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1054230_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1005919_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1137030_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1134263_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1134263_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1137030_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1137030_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1134263_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1134263_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1133530_PCP_ 84_4FF","quantities":[{"unit_id":"kg","value":7.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":36.0}],"visits_number":1,"minimum_lapse":144.0,"activity":{"point_id":"1133530","duration":144,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137030_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1137030_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1142237_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1142237_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1107406_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1121283_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124357_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1124357","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1130453_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1133530_PH _ 84_4FF","quantities":[{"unit_id":"kg","value":7.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":36.0}],"visits_number":1,"minimum_lapse":144.0,"activity":{"point_id":"1133530","duration":144,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133530_SAV_ 84_4FF","quantities":[{"unit_id":"kg","value":7.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":36.0}],"visits_number":1,"minimum_lapse":144.0,"activity":{"point_id":"1133530","duration":144,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1132589_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1107065_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1096970_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1005919_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1107065_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1005919_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1040631_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1137030_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1142237_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1142237_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1020782_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_CLI_ 42_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_INI_ 42_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1040631_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1030487_SNC_ 42_4FF","quantities":[{"unit_id":"kg","value":2.8},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":2,"minimum_lapse":180.0,"activity":{"point_id":"1030487","duration":180,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1007287_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":15.5},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":50.0}],"visits_number":3,"minimum_lapse":200.0,"activity":{"point_id":"1007287","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1005919_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1144594_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1143914_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":8.94},{"unit_id":"l","value":33.9},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1143914","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070735_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070749_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":7.35},{"unit_id":"l","value":89.6},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1070749","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1096970_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1096970_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1096970_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1031405_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.375},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1031405","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1031405_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.375},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1031405","duration":4,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1070735_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145751_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1145751","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1131394_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1131394_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1123348_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123348_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119750_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119750_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1120609_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1120609_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1120609_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":6.6},{"unit_id":"l","value":6.0},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1120609","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1119750_EMP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1107065_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1123348_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070735_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.7},{"unit_id":"l","value":7.0},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":28.0,"activity":{"point_id":"1070735","duration":28,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1123348_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123348","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1127546_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1133951_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137715_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137715_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1137715_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":130.0,"activity":{"point_id":"1137715","duration":130,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1131394_CLI_ 84_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1131394_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1131394_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.475},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1131394","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1127546_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127546_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.8},{"unit_id":"l","value":0.75},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1127546","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107065_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107065_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1109136_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1109136_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1109136_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1109136","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124357_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1124357","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1130453_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1121283_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107406_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004332_BOB_ 14_4FA","quantities":[{"unit_id":"kg","value":1.11},{"unit_id":"l","value":1.125},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004332","duration":12,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1030348_PH _ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1030348_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1030348_BOB_ 7_4FA","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":12,"minimum_lapse":156.0,"activity":{"point_id":"1030348","duration":156,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1107406_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1132589_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1143992_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1143992_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1040631_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_CLI_ 84_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1030463_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":9.66},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":46.0}],"visits_number":3,"minimum_lapse":184.0,"activity":{"point_id":"1030463","duration":184,"setup_duration":120,"timewindows":[{"start":30000,"end":68400,"day_index":0},{"start":30000,"end":68400,"day_index":1},{"start":30000,"end":68400,"day_index":2},{"start":30000,"end":68400,"day_index":3},{"start":30000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1107065_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1040631_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1001454_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1143992_SAV_ 84_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1143992_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1143992","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":62100,"day_index":0},{"start":32400,"end":62100,"day_index":1},{"start":32400,"end":62100,"day_index":2},{"start":32400,"end":62100,"day_index":3},{"start":32400,"end":62100,"day_index":4}]},"type":"service"},{"id":"1020782_CLI_ 42_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_INI_ 42_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1001454_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1001454_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":9.0,"activity":{"point_id":"1001454","duration":9,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1103574_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":7.305},{"unit_id":"l","value":14.7},{"unit_id":"qte","value":23.0}],"visits_number":3,"minimum_lapse":92.0,"activity":{"point_id":"1103574","duration":92,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1103574_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":7.305},{"unit_id":"l","value":14.7},{"unit_id":"qte","value":23.0}],"visits_number":3,"minimum_lapse":92.0,"activity":{"point_id":"1103574","duration":92,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004770_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004770_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1143914_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":8.94},{"unit_id":"l","value":33.9},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1143914","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004770_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.93},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1004770","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":48600,"end":64800,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":48600,"end":64800,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":48600,"end":64800,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":48600,"end":64800,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":48600,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_INI_ 84_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_CLI_ 84_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002504_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1096970_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1005919_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1005919_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_ASC_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1002504_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1144594_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144594_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":6.64},{"unit_id":"l","value":26.4},{"unit_id":"qte","value":2.0}],"visits_number":6,"minimum_lapse":200.0,"activity":{"point_id":"1144594","duration":200,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1107065_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1121101_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1121101_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1119750_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119750_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1119751_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119751_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119751_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1119751","duration":12,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1119750_EMP_ 14_4FF","quantities":[{"unit_id":"kg","value":39.0},{"unit_id":"l","value":184.68},{"unit_id":"qte","value":30.0}],"visits_number":6,"minimum_lapse":240.0,"activity":{"point_id":"1119750","duration":240,"setup_duration":120,"timewindows":[{"start":30600,"end":39600,"day_index":0},{"start":51300,"end":56700,"day_index":0},{"start":30600,"end":39600,"day_index":1},{"start":51300,"end":56700,"day_index":1},{"start":30600,"end":39600,"day_index":2},{"start":51300,"end":56700,"day_index":2},{"start":30600,"end":39600,"day_index":3},{"start":51300,"end":56700,"day_index":3},{"start":30600,"end":39600,"day_index":4},{"start":51300,"end":56700,"day_index":4}]},"type":"service"},{"id":"1096970_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":11.0},{"unit_id":"l","value":10.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1096970","duration":40,"setup_duration":120,"timewindows":[{"start":28800,"end":57600,"day_index":0},{"start":28800,"end":57600,"day_index":1},{"start":28800,"end":57600,"day_index":2},{"start":28800,"end":57600,"day_index":3},{"start":28800,"end":57600,"day_index":4}]},"type":"service"},{"id":"1121101_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1129651_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1129651","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1133951_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_DIF_ 84_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133951_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":136.0},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":360.0,"activity":{"point_id":"1133951","duration":360,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1133959_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1133959","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1129651_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1129651","duration":8,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1121101_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":3.3},{"unit_id":"l","value":3.0},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":12.0,"activity":{"point_id":"1121101","duration":12,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1099019_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1099019_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002504_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":77.0},{"unit_id":"l","value":217.0},{"unit_id":"qte","value":35.0}],"visits_number":12,"minimum_lapse":455.0,"activity":{"point_id":"1002504","duration":455,"setup_duration":120,"timewindows":[{"start":21600,"end":32400,"day_index":0},{"start":21600,"end":32400,"day_index":1},{"start":21600,"end":32400,"day_index":2},{"start":21600,"end":32400,"day_index":3},{"start":21600,"end":32400,"day_index":4}]},"type":"service"},{"id":"1107406_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1121283_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1121283","duration":8,"setup_duration":120,"timewindows":[{"start":36000,"end":64800,"day_index":0},{"start":36000,"end":64800,"day_index":1},{"start":36000,"end":64800,"day_index":2},{"start":36000,"end":64800,"day_index":3},{"start":36000,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124357_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":90.0,"activity":{"point_id":"1124357","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1130453_BOB_ 14_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_CLI_ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1130453_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":52.8},{"unit_id":"l","value":148.8},{"unit_id":"qte","value":24.0}],"visits_number":6,"minimum_lapse":312.0,"activity":{"point_id":"1130453","duration":312,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132589_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1005919_BOB_ 7_4FF","quantities":[{"unit_id":"kg","value":89.46},{"unit_id":"l","value":276.0},{"unit_id":"qte","value":60.0}],"visits_number":12,"minimum_lapse":274.0,"activity":{"point_id":"1005919","duration":274,"setup_duration":120,"timewindows":[{"start":24300,"end":43200,"day_index":0},{"start":24300,"end":43200,"day_index":1},{"start":24300,"end":43200,"day_index":2},{"start":24300,"end":43200,"day_index":3},{"start":24300,"end":43200,"day_index":4}]},"type":"service"},{"id":"1142237_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1088315_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":2.8},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1088315","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1088315_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":2.8},{"unit_id":"l","value":30.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1088315","duration":180,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_CLI_ 84_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_DIF_ 42_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1087334_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":60.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":360.0,"activity":{"point_id":"1087334","duration":360,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054230_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":0.0},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":1,"minimum_lapse":80.0,"activity":{"point_id":"1054230","duration":80,"setup_duration":120,"timewindows":[{"start":36000,"end":61200,"day_index":0},{"start":36000,"end":61200,"day_index":1},{"start":36000,"end":61200,"day_index":2},{"start":36000,"end":61200,"day_index":3},{"start":36000,"end":61200,"day_index":4}]},"type":"service"},{"id":"1058540_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":180.0,"activity":{"point_id":"1058540","duration":180,"setup_duration":120,"timewindows":[{"start":30600,"end":72000,"day_index":0},{"start":30600,"end":72000,"day_index":1},{"start":30600,"end":72000,"day_index":2},{"start":30600,"end":72000,"day_index":3},{"start":30600,"end":72000,"day_index":4}]},"type":"service"},{"id":"1109631_PCP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1109631","duration":40,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":32400,"end":43200,"day_index":4}]},"type":"service"},{"id":"1142237_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1137030_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1020782_SAV_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1020782_DIF_ 42_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1040631_TAP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1040631_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":23.1},{"unit_id":"l","value":21.0},{"unit_id":"qte","value":21.0}],"visits_number":3,"minimum_lapse":35.0,"activity":{"point_id":"1040631","duration":35,"setup_duration":120,"timewindows":[{"start":27000,"end":68400,"day_index":0},{"start":27000,"end":68400,"day_index":1},{"start":27000,"end":68400,"day_index":2},{"start":27000,"end":68400,"day_index":3},{"start":27000,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_PH _ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1107065_PCP_ 14_4FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":4.0,"activity":{"point_id":"1107065","duration":4,"setup_duration":120,"timewindows":[{"start":39600,"end":68400,"day_index":0},{"start":39600,"end":68400,"day_index":1},{"start":39600,"end":68400,"day_index":2},{"start":39600,"end":68400,"day_index":3},{"start":39600,"end":68400,"day_index":4}]},"type":"service"},{"id":"1099019_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":43.364},{"unit_id":"l","value":123.8},{"unit_id":"qte","value":21.0}],"visits_number":6,"minimum_lapse":273.0,"activity":{"point_id":"1099019","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1020782_SNC_ 14_4FF","quantities":[{"unit_id":"kg","value":68.77},{"unit_id":"l","value":126.0},{"unit_id":"qte","value":217.0}],"visits_number":6,"minimum_lapse":556.0,"activity":{"point_id":"1020782","duration":556,"setup_duration":120,"timewindows":[{"start":27000,"end":57600,"day_index":0},{"start":27000,"end":57600,"day_index":1},{"start":27000,"end":57600,"day_index":2},{"start":27000,"end":57600,"day_index":3},{"start":27000,"end":57600,"day_index":4}]},"type":"service"},{"id":"1137030_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1106440_TAP_ 7_4FF","quantities":[{"unit_id":"kg","value":3.32},{"unit_id":"l","value":13.2},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":200.0,"activity":{"point_id":"1106440","duration":200,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1142237_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1137030_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1134263_BOB_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1134263_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1134263_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1134263_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":12.06},{"unit_id":"l","value":75.6},{"unit_id":"qte","value":36.0}],"visits_number":3,"minimum_lapse":144.0,"activity":{"point_id":"1134263","duration":144,"setup_duration":120,"timewindows":[{"start":30600,"end":66600,"day_index":0},{"start":30600,"end":66600,"day_index":1},{"start":30600,"end":66600,"day_index":2},{"start":30600,"end":66600,"day_index":3},{"start":30600,"end":66600,"day_index":4}]},"type":"service"},{"id":"1137030_EMP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1137030_PCP_ 28_4FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1137030","duration":40,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":63000,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":63000,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":63000,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":63000,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1132589_PH _ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1132589_SAV_ 28_4FF","quantities":[{"unit_id":"kg","value":48.4},{"unit_id":"l","value":136.4},{"unit_id":"qte","value":22.0}],"visits_number":12,"minimum_lapse":286.0,"activity":{"point_id":"1132589","duration":286,"setup_duration":120,"timewindows":[{"start":23400,"end":30600,"day_index":0},{"start":23400,"end":30600,"day_index":1},{"start":23400,"end":30600,"day_index":2},{"start":23400,"end":30600,"day_index":3},{"start":23400,"end":30600,"day_index":4}]},"type":"service"},{"id":"1142237_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":9.1},{"unit_id":"l","value":43.092},{"unit_id":"qte","value":7.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1142237","duration":56,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1107406_SNC_ 28_4FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1107406","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":61200,"day_index":0},{"start":28800,"end":61200,"day_index":1},{"start":28800,"end":61200,"day_index":2},{"start":28800,"end":61200,"day_index":3},{"start":28800,"end":61200,"day_index":4}]},"type":"service"},{"id":"1120539_EMP_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1120539","duration":8,"setup_duration":120,"timewindows":[{"start":34200,"end":64800,"day_index":0},{"start":34200,"end":64800,"day_index":1},{"start":34200,"end":64800,"day_index":2},{"start":34200,"end":64800,"day_index":3},{"start":34200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1120539_SAV_ 28_4FA","quantities":[{"unit_id":"kg","value":2.2},{"unit_id":"l","value":2.0},{"unit_id":"qte","value":2.0}],"visits_number":3,"minimum_lapse":8.0,"activity":{"point_id":"1120539","duration":8,"setup_duration":120,"timewindows":[{"start":34200,"end":64800,"day_index":0},{"start":34200,"end":64800,"day_index":1},{"start":34200,"end":64800,"day_index":2},{"start":34200,"end":64800,"day_index":3},{"start":34200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_PH _ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_DIF_ 42_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_SNC_ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_PCP_ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004716_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":6,"minimum_lapse":104.0,"activity":{"point_id":"1004716","duration":104,"setup_duration":120,"timewindows":[{"start":30600,"end":43200,"day_index":0},{"start":30600,"end":43200,"day_index":1},{"start":30600,"end":43200,"day_index":2},{"start":30600,"end":43200,"day_index":3},{"start":30600,"end":43200,"day_index":4}]},"type":"service"},{"id":"1144936_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":10.85},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":35.0}],"visits_number":3,"minimum_lapse":140.0,"activity":{"point_id":"1144936","duration":140,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144936_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":10.85},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":35.0}],"visits_number":3,"minimum_lapse":140.0,"activity":{"point_id":"1144936","duration":140,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144936_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":10.85},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":35.0}],"visits_number":3,"minimum_lapse":140.0,"activity":{"point_id":"1144936","duration":140,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134666_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134666","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1006725_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":3.15},{"unit_id":"l","value":38.4},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1006725","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":65700,"day_index":0},{"start":32400,"end":65700,"day_index":1},{"start":32400,"end":65700,"day_index":2},{"start":32400,"end":65700,"day_index":3},{"start":32400,"end":65700,"day_index":4}]},"type":"service"},{"id":"1006725_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":3.15},{"unit_id":"l","value":38.4},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1006725","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":65700,"day_index":0},{"start":32400,"end":65700,"day_index":1},{"start":32400,"end":65700,"day_index":2},{"start":32400,"end":65700,"day_index":3},{"start":32400,"end":65700,"day_index":4}]},"type":"service"},{"id":"1092502_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":2.01},{"unit_id":"l","value":12.6},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1092502","duration":24,"setup_duration":120,"timewindows":[{"start":30600,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":30600,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":30600,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":30600,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":30600,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1092502_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":2.01},{"unit_id":"l","value":12.6},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1092502","duration":24,"setup_duration":120,"timewindows":[{"start":30600,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":30600,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":30600,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":30600,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":30600,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1092502_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":2.01},{"unit_id":"l","value":12.6},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1092502","duration":24,"setup_duration":120,"timewindows":[{"start":30600,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":30600,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":30600,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":30600,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":30600,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1092502_CLI_ 84_3FF","quantities":[{"unit_id":"kg","value":2.01},{"unit_id":"l","value":12.6},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1092502","duration":24,"setup_duration":120,"timewindows":[{"start":30600,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":30600,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":30600,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":30600,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":30600,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1008001_TAP_ 28_3FF","quantities":[{"unit_id":"kg","value":7.5},{"unit_id":"l","value":29.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":115.0,"activity":{"point_id":"1008001","duration":115,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1006725_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":3.15},{"unit_id":"l","value":38.4},{"unit_id":"qte","value":3.0}],"visits_number":3,"minimum_lapse":24.0,"activity":{"point_id":"1006725","duration":24,"setup_duration":120,"timewindows":[{"start":32400,"end":65700,"day_index":0},{"start":32400,"end":65700,"day_index":1},{"start":32400,"end":65700,"day_index":2},{"start":32400,"end":65700,"day_index":3},{"start":32400,"end":65700,"day_index":4}]},"type":"service"},{"id":"1008001_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":7.5},{"unit_id":"l","value":29.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":115.0,"activity":{"point_id":"1008001","duration":115,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1008001_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":7.5},{"unit_id":"l","value":29.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":115.0,"activity":{"point_id":"1008001","duration":115,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1008001_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":7.5},{"unit_id":"l","value":29.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":115.0,"activity":{"point_id":"1008001","duration":115,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1144936_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":10.85},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":35.0}],"visits_number":3,"minimum_lapse":140.0,"activity":{"point_id":"1144936","duration":140,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144493_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1144493","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144493_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1144493","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144493_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":76.8},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1144493","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1147114_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":2.94},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1147114","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1147114_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":2.94},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":14.0}],"visits_number":3,"minimum_lapse":56.0,"activity":{"point_id":"1147114","duration":56,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1147721_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1147721_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1147721_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1147721_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1147721_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1147721_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":17.6},{"unit_id":"l","value":49.6},{"unit_id":"qte","value":8.0}],"visits_number":3,"minimum_lapse":104.0,"activity":{"point_id":"1147721","duration":104,"setup_duration":120,"timewindows":[{"start":32400,"end":46800,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":46800,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":46800,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":46800,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":46800,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1003152_SNC_ 42_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1110450_BOB_ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070260_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1070260","duration":40,"setup_duration":120,"timewindows":[{"start":25200,"end":64800,"day_index":0},{"start":25200,"end":64800,"day_index":1},{"start":25200,"end":64800,"day_index":2},{"start":25200,"end":64800,"day_index":3},{"start":25200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1110450_PH _ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1110450_TAP_ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134666_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134666","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134666_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134666","duration":16,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132451_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1132451","duration":90,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132451_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1132451","duration":90,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132451_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1132451","duration":90,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1132451_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1132451","duration":90,"setup_duration":120,"timewindows":[{"start":34200,"end":45000,"day_index":0},{"start":50400,"end":64800,"day_index":0},{"start":34200,"end":45000,"day_index":1},{"start":50400,"end":64800,"day_index":1},{"start":34200,"end":45000,"day_index":2},{"start":50400,"end":64800,"day_index":2},{"start":34200,"end":45000,"day_index":3},{"start":50400,"end":64800,"day_index":3},{"start":34200,"end":45000,"day_index":4},{"start":50400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1122595_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":28.6},{"unit_id":"l","value":80.6},{"unit_id":"qte","value":13.0}],"visits_number":3,"minimum_lapse":169.0,"activity":{"point_id":"1122595","duration":169,"setup_duration":120,"timewindows":[{"start":28800,"end":39600,"day_index":0},{"start":28800,"end":39600,"day_index":1},{"start":28800,"end":39600,"day_index":2},{"start":28800,"end":39600,"day_index":3},{"start":28800,"end":39600,"day_index":4}]},"type":"service"},{"id":"1122595_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":28.6},{"unit_id":"l","value":80.6},{"unit_id":"qte","value":13.0}],"visits_number":3,"minimum_lapse":169.0,"activity":{"point_id":"1122595","duration":169,"setup_duration":120,"timewindows":[{"start":28800,"end":39600,"day_index":0},{"start":28800,"end":39600,"day_index":1},{"start":28800,"end":39600,"day_index":2},{"start":28800,"end":39600,"day_index":3},{"start":28800,"end":39600,"day_index":4}]},"type":"service"},{"id":"1122595_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":28.6},{"unit_id":"l","value":80.6},{"unit_id":"qte","value":13.0}],"visits_number":3,"minimum_lapse":169.0,"activity":{"point_id":"1122595","duration":169,"setup_duration":120,"timewindows":[{"start":28800,"end":39600,"day_index":0},{"start":28800,"end":39600,"day_index":1},{"start":28800,"end":39600,"day_index":2},{"start":28800,"end":39600,"day_index":3},{"start":28800,"end":39600,"day_index":4}]},"type":"service"},{"id":"1110450_SAV_ 14_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1003152_TAP_ 7_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070260_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1070260","duration":40,"setup_duration":120,"timewindows":[{"start":25200,"end":64800,"day_index":0},{"start":25200,"end":64800,"day_index":1},{"start":25200,"end":64800,"day_index":2},{"start":25200,"end":64800,"day_index":3},{"start":25200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1070260_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1070260","duration":40,"setup_duration":120,"timewindows":[{"start":25200,"end":64800,"day_index":0},{"start":25200,"end":64800,"day_index":1},{"start":25200,"end":64800,"day_index":2},{"start":25200,"end":64800,"day_index":3},{"start":25200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134348_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134348","duration":16,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1134348_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134348","duration":16,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127201_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":14.28},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1127201","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134348_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134348","duration":16,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1127201_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":14.28},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":68.0}],"visits_number":3,"minimum_lapse":272.0,"activity":{"point_id":"1127201","duration":272,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138580_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":5.2},{"unit_id":"l","value":24.624},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1138580","duration":32,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138580_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":5.2},{"unit_id":"l","value":24.624},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1138580","duration":32,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138580_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":5.2},{"unit_id":"l","value":24.624},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1138580","duration":32,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1143039_TAP_ 14_3FF","quantities":[{"unit_id":"kg","value":26.56},{"unit_id":"l","value":105.6},{"unit_id":"qte","value":8.0}],"visits_number":6,"minimum_lapse":800.0,"activity":{"point_id":"1143039","duration":800,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1134348_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":4.4},{"unit_id":"l","value":4.0},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":16.0,"activity":{"point_id":"1134348","duration":16,"setup_duration":120,"timewindows":[{"start":30600,"end":61200,"day_index":0},{"start":30600,"end":61200,"day_index":1},{"start":30600,"end":61200,"day_index":2},{"start":30600,"end":61200,"day_index":3},{"start":30600,"end":61200,"day_index":4}]},"type":"service"},{"id":"1132224_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1132224","duration":4,"setup_duration":120,"timewindows":[{"start":34200,"end":70200,"day_index":0},{"start":34200,"end":70200,"day_index":1},{"start":34200,"end":70200,"day_index":2},{"start":34200,"end":70200,"day_index":3},{"start":34200,"end":70200,"day_index":4}]},"type":"service"},{"id":"1132224_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1132224","duration":4,"setup_duration":120,"timewindows":[{"start":34200,"end":70200,"day_index":0},{"start":34200,"end":70200,"day_index":1},{"start":34200,"end":70200,"day_index":2},{"start":34200,"end":70200,"day_index":3},{"start":34200,"end":70200,"day_index":4}]},"type":"service"},{"id":"1110450_PH _ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1110450_TAP_ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1095177_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":51.2},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1095177","duration":32,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1111407_TAP_ 14_3FF","quantities":[{"unit_id":"kg","value":37.5},{"unit_id":"l","value":145.0},{"unit_id":"qte","value":5.0}],"visits_number":6,"minimum_lapse":500.0,"activity":{"point_id":"1111407","duration":500,"setup_duration":120,"timewindows":[{"start":25200,"end":50400,"day_index":0},{"start":25200,"end":50400,"day_index":1},{"start":25200,"end":50400,"day_index":2},{"start":25200,"end":50400,"day_index":3},{"start":25200,"end":50400,"day_index":4}]},"type":"service"},{"id":"1117925_EMP_ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1117925","duration":48,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1117925_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1117925","duration":48,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1117925_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1117925","duration":48,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1117925_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":48.0,"activity":{"point_id":"1117925","duration":48,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1132224_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":1.1},{"unit_id":"l","value":1.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1132224","duration":4,"setup_duration":120,"timewindows":[{"start":34200,"end":70200,"day_index":0},{"start":34200,"end":70200,"day_index":1},{"start":34200,"end":70200,"day_index":2},{"start":34200,"end":70200,"day_index":3},{"start":34200,"end":70200,"day_index":4}]},"type":"service"},{"id":"1138580_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":5.2},{"unit_id":"l","value":24.624},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1138580","duration":32,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1135294_CLI_ 28_3FF","quantities":[{"unit_id":"kg","value":0.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1135294","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1135294_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":0.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1135294","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1135294_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":0.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1135294","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031534_PH _ 84_3FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":16.0,"activity":{"point_id":"1031534","duration":16,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1031534_SAV_ 84_3FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":16.0,"activity":{"point_id":"1031534","duration":16,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1047944_PH _ 14_3FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":31.0,"activity":{"point_id":"1047944","duration":31,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1047944_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":1.05},{"unit_id":"l","value":12.8},{"unit_id":"qte","value":1.0}],"visits_number":6,"minimum_lapse":31.0,"activity":{"point_id":"1047944","duration":31,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1050281_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":52.0,"activity":{"point_id":"1050281","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1054024_SNC_ 7_3FF","quantities":[{"unit_id":"kg","value":6.3},{"unit_id":"l","value":102.0},{"unit_id":"qte","value":3.0}],"visits_number":12,"minimum_lapse":270.0,"activity":{"point_id":"1054024","duration":270,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1050281_PCP_ 14_3FF","quantities":[{"unit_id":"kg","value":8.8},{"unit_id":"l","value":24.8},{"unit_id":"qte","value":4.0}],"visits_number":6,"minimum_lapse":52.0,"activity":{"point_id":"1050281","duration":52,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1040973_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":28.6},{"unit_id":"l","value":80.6},{"unit_id":"qte","value":13.0}],"visits_number":6,"minimum_lapse":186.0,"activity":{"point_id":"1040973","duration":186,"setup_duration":120,"timewindows":[{"start":21600,"end":75600,"day_index":0},{"start":21600,"end":75600,"day_index":1},{"start":21600,"end":75600,"day_index":2},{"start":21600,"end":75600,"day_index":3},{"start":21600,"end":75600,"day_index":4}]},"type":"service"},{"id":"1063338_SNC_ 7_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":34.0},{"unit_id":"qte","value":1.0}],"visits_number":12,"minimum_lapse":90.0,"activity":{"point_id":"1063338","duration":90,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1031534_CLI_ 84_3FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":16.0,"activity":{"point_id":"1031534","duration":16,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1070260_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":5.25},{"unit_id":"l","value":64.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":40.0,"activity":{"point_id":"1070260","duration":40,"setup_duration":120,"timewindows":[{"start":25200,"end":64800,"day_index":0},{"start":25200,"end":64800,"day_index":1},{"start":25200,"end":64800,"day_index":2},{"start":25200,"end":64800,"day_index":3},{"start":25200,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031534_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":16.0,"activity":{"point_id":"1031534","duration":16,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1031918_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":277.0,"activity":{"point_id":"1031918","duration":277,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1135294_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":0.1},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":4.0,"activity":{"point_id":"1135294","duration":4,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1138580_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":5.2},{"unit_id":"l","value":24.624},{"unit_id":"qte","value":4.0}],"visits_number":3,"minimum_lapse":32.0,"activity":{"point_id":"1138580","duration":32,"setup_duration":120,"timewindows":[{"start":32400,"end":64800,"day_index":0},{"start":32400,"end":64800,"day_index":1},{"start":32400,"end":64800,"day_index":2},{"start":32400,"end":64800,"day_index":3},{"start":32400,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145151_EMP_ 14_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145151_PH _ 14_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1054036_SNC_ 7_3FF","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":180.0,"activity":{"point_id":"1054036","duration":180,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1003152_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1003152_ASC_ 28_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1003152_TAP_ 7_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1003152_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1031534_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":2.1},{"unit_id":"l","value":25.6},{"unit_id":"qte","value":2.0}],"visits_number":1,"minimum_lapse":16.0,"activity":{"point_id":"1031534","duration":16,"setup_duration":120,"timewindows":[{"start":32400,"end":68400,"day_index":0},{"start":32400,"end":68400,"day_index":1},{"start":32400,"end":68400,"day_index":2},{"start":32400,"end":68400,"day_index":3},{"start":32400,"end":68400,"day_index":4}]},"type":"service"},{"id":"1110450_BOB_ 7_3FF","quantities":[{"unit_id":"kg","value":46.2},{"unit_id":"l","value":130.2},{"unit_id":"qte","value":21.0}],"visits_number":12,"minimum_lapse":273.0,"activity":{"point_id":"1110450","duration":273,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004708_LPL_ 28_3FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":182.0,"activity":{"point_id":"1004708","duration":182,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1004708_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":0.4},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":5.0}],"visits_number":3,"minimum_lapse":182.0,"activity":{"point_id":"1004708","duration":182,"setup_duration":120,"timewindows":[{"start":32400,"end":43200,"day_index":0},{"start":50400,"end":61200,"day_index":0},{"start":32400,"end":43200,"day_index":1},{"start":50400,"end":61200,"day_index":1},{"start":32400,"end":43200,"day_index":2},{"start":50400,"end":61200,"day_index":2},{"start":32400,"end":43200,"day_index":3},{"start":50400,"end":61200,"day_index":3},{"start":32400,"end":43200,"day_index":4},{"start":50400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1145151_EMP_ 14_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1054036_SNC_ 7_3FF","quantities":[{"unit_id":"kg","value":4.2},{"unit_id":"l","value":68.0},{"unit_id":"qte","value":2.0}],"visits_number":12,"minimum_lapse":180.0,"activity":{"point_id":"1054036","duration":180,"setup_duration":120,"timewindows":[{"start":27000,"end":72000,"day_index":0},{"start":27000,"end":72000,"day_index":1},{"start":27000,"end":72000,"day_index":2},{"start":27000,"end":72000,"day_index":3},{"start":27000,"end":72000,"day_index":4}]},"type":"service"},{"id":"1003152_TAP_ 7_3FF","quantities":[{"unit_id":"kg","value":7.2},{"unit_id":"l","value":141.0},{"unit_id":"qte","value":3.0}],"visits_number":2,"minimum_lapse":270.0,"activity":{"point_id":"1003152","duration":270,"setup_duration":120,"timewindows":[{"start":27900,"end":64800,"day_index":0},{"start":27900,"end":64800,"day_index":1},{"start":27900,"end":64800,"day_index":2},{"start":27900,"end":64800,"day_index":3},{"start":27900,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145151_PH _ 14_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002561_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":46.0,"activity":{"point_id":"1002561","duration":46,"setup_duration":120,"timewindows":[{"start":29700,"end":64800,"day_index":0},{"start":29700,"end":64800,"day_index":1},{"start":29700,"end":64800,"day_index":2},{"start":29700,"end":64800,"day_index":3},{"start":29700,"end":64800,"day_index":4}]},"type":"service"},{"id":"1002561_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":46.0,"activity":{"point_id":"1002561","duration":46,"setup_duration":120,"timewindows":[{"start":29700,"end":64800,"day_index":0},{"start":29700,"end":64800,"day_index":1},{"start":29700,"end":64800,"day_index":2},{"start":29700,"end":64800,"day_index":3},{"start":29700,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005880_EMP_ 42_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":41.72},{"unit_id":"qte","value":7.0}],"visits_number":2,"minimum_lapse":61.0,"activity":{"point_id":"1005880","duration":61,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005880_SAV_ 42_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":41.72},{"unit_id":"qte","value":7.0}],"visits_number":2,"minimum_lapse":61.0,"activity":{"point_id":"1005880","duration":61,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1002561_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":9.6},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":3,"minimum_lapse":46.0,"activity":{"point_id":"1002561","duration":46,"setup_duration":120,"timewindows":[{"start":29700,"end":64800,"day_index":0},{"start":29700,"end":64800,"day_index":1},{"start":29700,"end":64800,"day_index":2},{"start":29700,"end":64800,"day_index":3},{"start":29700,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145151_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1145151_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":7.8},{"unit_id":"l","value":36.936},{"unit_id":"qte","value":6.0}],"visits_number":6,"minimum_lapse":48.0,"activity":{"point_id":"1145151","duration":48,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144485_LPL_ 28_3FF","quantities":[{"unit_id":"kg","value":2.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":32.0}],"visits_number":3,"minimum_lapse":416.0,"activity":{"point_id":"1144485","duration":416,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1116199_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":18.46},{"unit_id":"l","value":42.0},{"unit_id":"qte","value":76.0}],"visits_number":3,"minimum_lapse":304.0,"activity":{"point_id":"1116199","duration":304,"setup_duration":120,"timewindows":[{"start":33300,"end":61200,"day_index":0},{"start":33300,"end":61200,"day_index":1},{"start":33300,"end":61200,"day_index":2},{"start":33300,"end":61200,"day_index":3},{"start":33300,"end":61200,"day_index":4}]},"type":"service"},{"id":"1123435_SNC_ 28_3FF","quantities":[{"unit_id":"kg","value":2.4},{"unit_id":"l","value":47.0},{"unit_id":"qte","value":1.0}],"visits_number":3,"minimum_lapse":90.0,"activity":{"point_id":"1123435","duration":90,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124213_BOB_ 28_3FF","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":78.0,"activity":{"point_id":"1124213","duration":78,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124213_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":78.0,"activity":{"point_id":"1124213","duration":78,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1124213_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":13.2},{"unit_id":"l","value":37.2},{"unit_id":"qte","value":6.0}],"visits_number":3,"minimum_lapse":78.0,"activity":{"point_id":"1124213","duration":78,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144485_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":2.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":32.0}],"visits_number":3,"minimum_lapse":416.0,"activity":{"point_id":"1144485","duration":416,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144485_CLI_ 28_3FF","quantities":[{"unit_id":"kg","value":2.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":32.0}],"visits_number":3,"minimum_lapse":416.0,"activity":{"point_id":"1144485","duration":416,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1144485_PCP_ 28_3FF","quantities":[{"unit_id":"kg","value":2.56},{"unit_id":"l","value":0.0},{"unit_id":"qte","value":32.0}],"visits_number":3,"minimum_lapse":416.0,"activity":{"point_id":"1144485","duration":416,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1143039_TAP_ 14_3FF","quantities":[{"unit_id":"kg","value":26.56},{"unit_id":"l","value":105.6},{"unit_id":"qte","value":8.0}],"visits_number":6,"minimum_lapse":800.0,"activity":{"point_id":"1143039","duration":800,"setup_duration":120,"timewindows":[{"start":28800,"end":64800,"day_index":0},{"start":28800,"end":64800,"day_index":1},{"start":28800,"end":64800,"day_index":2},{"start":28800,"end":64800,"day_index":3},{"start":28800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1005880_PH _ 42_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":41.72},{"unit_id":"qte","value":7.0}],"visits_number":2,"minimum_lapse":61.0,"activity":{"point_id":"1005880","duration":61,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1005880_SNC_ 42_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":41.72},{"unit_id":"qte","value":7.0}],"visits_number":2,"minimum_lapse":61.0,"activity":{"point_id":"1005880","duration":61,"setup_duration":120,"timewindows":[{"start":32400,"end":61200,"day_index":0},{"start":32400,"end":61200,"day_index":1},{"start":32400,"end":61200,"day_index":2},{"start":32400,"end":61200,"day_index":3},{"start":32400,"end":61200,"day_index":4}]},"type":"service"},{"id":"1031918_BOB_ 14_3FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":277.0,"activity":{"point_id":"1031918","duration":277,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1031918_PH _ 28_3FF","quantities":[{"unit_id":"kg","value":22.0},{"unit_id":"l","value":62.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":277.0,"activity":{"point_id":"1031918","duration":277,"setup_duration":120,"timewindows":[{"start":32400,"end":63000,"day_index":0},{"start":32400,"end":63000,"day_index":1},{"start":32400,"end":63000,"day_index":2},{"start":32400,"end":63000,"day_index":3},{"start":32400,"end":63000,"day_index":4}]},"type":"service"},{"id":"1004647_SNC_ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_SAV_ 28_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_PH _ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"},{"id":"1004647_PCP_ 14_3FF","quantities":[{"unit_id":"kg","value":10.5},{"unit_id":"l","value":128.0},{"unit_id":"qte","value":10.0}],"visits_number":6,"minimum_lapse":80.0,"activity":{"point_id":"1004647","duration":80,"setup_duration":120,"timewindows":[{"start":37800,"end":64800,"day_index":0},{"start":37800,"end":64800,"day_index":1},{"start":37800,"end":64800,"day_index":2},{"start":37800,"end":64800,"day_index":3},{"start":37800,"end":64800,"day_index":4}]},"type":"service"}],"vehicles":[{"id":"vehicule1","start_point_id":"startvehicule1","end_point_id":"endvehicule1","router_mode":"car","speed_multiplier":0.75,"cost_time_multiplier":1.0,"router_dimension":"time","skills":[["vehicule1"]],"sequence_timewindows":[{"start":21600,"end":45000,"day_index":0},{"start":21600,"end":45000,"day_index":1},{"start":21600,"end":45000,"day_index":2},{"start":21600,"end":45000,"day_index":3},{"start":21600,"end":45000,"day_index":4}],"capacities":[{"unit_id":"kg","limit":850.0},{"unit_id":"l","limit":7435.0},{"unit_id":"qte","limit":9999.0}],"unavailable_work_day_indices":[5,6],"traffic":true,"track":true,"motorway":true,"toll":true,"max_walk_distance":750,"approach":"unrestricted"},{"id":"vehicule2","start_point_id":"startvehicule2","end_point_id":"endvehicule2","router_mode":"car","speed_multiplier":0.75,"cost_time_multiplier":1.0,"router_dimension":"time","skills":"vehicule1,vehicule2","sequence_timewindows":[{"start":21600,"end":45000,"day_index":0},{"start":21600,"end":45000,"day_index":1},{"start":21600,"end":45000,"day_index":2},{"start":21600,"end":45000,"day_index":3},{"start":21600,"end":45000,"day_index":4}],"capacities":[{"unit_id":"kg","limit":1210.0},{"unit_id":"l","limit":6254.0},{"unit_id":"qte","limit":9999.0}],"unavailable_work_day_indices":[5,6],"traffic":true,"track":true,"motorway":true,"toll":true,"max_walk_distance":750,"approach":"unrestricted"}],"configuration":{"preprocessing":{"use_periodic_heuristic":true,"prefer_short_segment":true,"partition_method":"balanced_kmeans","partition_metric":"duration"},"resolution":{"same_point_day":true,"solver_parameter":-1,"duration":225000,"initial_time_out":112500,"time_out_multiplier":2},"schedule":{"range_indices":{"start":0,"end":83}},"restitution":{"csv":true,"intermediate_solutions":false}}}} diff --git a/benchmark/simple.rb b/benchmark/simple.rb index 053c33351..133050ae2 100644 --- a/benchmark/simple.rb +++ b/benchmark/simple.rb @@ -13,7 +13,7 @@ class API < Grape::API end options = { - method: 'GET' + method: Rack::GET } env = Rack::MockRequest.env_for('/api/v1', options) diff --git a/benchmark/simple_with_type_coercer.rb b/benchmark/simple_with_type_coercer.rb deleted file mode 100644 index 1dc0e7be4..000000000 --- a/benchmark/simple_with_type_coercer.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) -require 'grape' -require 'benchmark/ips' - -api = Class.new(Grape::API) do - prefix :api - version 'v1', using: :path - params do - requires :param, type: Array[String] - end - get '/' do - 'hello' - end -end - -env = Rack::MockRequest.env_for('/api/v1?param=value', method: 'GET') - -Benchmark.ips do |ips| - ips.report('simple_with_type_coercer') do - api.call(env) - end -end diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..2b293708b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3' + +volumes: + gems: + +services: + grape: + build: + context: . + dockerfile: docker/Dockerfile + args: + - RUBY_VERSION=${RUBY_VERSION:-3} + stdin_open: true + tty: true + volumes: + - .:/var/grape + - gems:/usr/local/bundle diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..2bc484ecf --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,18 @@ +ARG RUBY_VERSION=3 +FROM ruby:${RUBY_VERSION}-alpine + +ENV BUNDLE_PATH /usr/local/bundle/gems +ENV LIB_PATH /var/grape +ENV RUBYOPT --enable-frozen-string-literal --yjit +ENV LD_PRELOAD libjemalloc.so.2 +ENV MALLOC_CONF dirty_decay_ms:1000,narenas:2,background_thread:true + +RUN apk add --update --no-cache make gcc git libc-dev gcompat jemalloc && \ + gem update --system && gem install bundler + +WORKDIR $LIB_PATH + +COPY /docker/entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 000000000..bc492f395 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh + +set -e + +# Useful information +echo -e "$(ruby --version)\nrubygems $(gem --version)\n$(bundle version)" +if [ -z "${GEMFILE}" ] +then + echo "Running default Gemfile" +else + export BUNDLE_GEMFILE="./gemfiles/${GEMFILE}.gemfile" + echo "Running gemfile: ${GEMFILE}" +fi + +# Keep gems in the latest possible state +(bundle check || bundle install) && bundle update && exec bundle exec ${@} diff --git a/gemfiles/dry_validation.gemfile b/gemfiles/dry_validation.gemfile new file mode 100644 index 000000000..ad526a00f --- /dev/null +++ b/gemfiles/dry_validation.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'dry-validation' diff --git a/gemfiles/grape_entity.gemfile b/gemfiles/grape_entity.gemfile new file mode 100644 index 000000000..6baf20174 --- /dev/null +++ b/gemfiles/grape_entity.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'grape-entity' diff --git a/gemfiles/hashie.gemfile b/gemfiles/hashie.gemfile new file mode 100644 index 000000000..c45181b01 --- /dev/null +++ b/gemfiles/hashie.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'hashie' diff --git a/gemfiles/multi_json.gemfile b/gemfiles/multi_json.gemfile index 0cb8517c1..014c2f9c6 100644 --- a/gemfiles/multi_json.gemfile +++ b/gemfiles/multi_json.gemfile @@ -1,38 +1,5 @@ # frozen_string_literal: true -# This file was generated by Appraisal +gem 'multi_json' -source 'https://rubygems.org' - -gem 'multi_json', require: 'multi_json' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' +eval_gemfile '../Gemfile' diff --git a/gemfiles/multi_xml.gemfile b/gemfiles/multi_xml.gemfile index d352caf98..3cd7774f9 100644 --- a/gemfiles/multi_xml.gemfile +++ b/gemfiles/multi_xml.gemfile @@ -1,38 +1,5 @@ # frozen_string_literal: true -# This file was generated by Appraisal +gem 'multi_xml' -source 'https://rubygems.org' - -gem 'multi_xml', require: 'multi_xml' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' +eval_gemfile '../Gemfile' diff --git a/gemfiles/rack1.gemfile b/gemfiles/rack1.gemfile deleted file mode 100644 index 836f9c902..000000000 --- a/gemfiles/rack1.gemfile +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# This file was generated by Appraisal - -source 'https://rubygems.org' - -gem 'rack', '~> 1.0' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' diff --git a/gemfiles/rack2.gemfile b/gemfiles/rack2.gemfile deleted file mode 100644 index 9f92d32ff..000000000 --- a/gemfiles/rack2.gemfile +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# This file was generated by Appraisal - -source 'https://rubygems.org' - -gem 'rack', '~> 2.0' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' diff --git a/gemfiles/rack_2_0.gemfile b/gemfiles/rack_2_0.gemfile new file mode 100644 index 000000000..f43035ba6 --- /dev/null +++ b/gemfiles/rack_2_0.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rack', '~> 2.0' diff --git a/gemfiles/rack_3_0.gemfile b/gemfiles/rack_3_0.gemfile new file mode 100644 index 000000000..4025f3cc2 --- /dev/null +++ b/gemfiles/rack_3_0.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rack', '~> 3.0.0' diff --git a/gemfiles/rack_3_1.gemfile b/gemfiles/rack_3_1.gemfile new file mode 100644 index 000000000..c57807e9f --- /dev/null +++ b/gemfiles/rack_3_1.gemfile @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rack', '~> 3.1' diff --git a/gemfiles/rack_edge.gemfile b/gemfiles/rack_edge.gemfile index 185c39391..975ceedbf 100644 --- a/gemfiles/rack_edge.gemfile +++ b/gemfiles/rack_edge.gemfile @@ -1,38 +1,5 @@ # frozen_string_literal: true -# This file was generated by Appraisal - -source 'https://rubygems.org' +eval_gemfile '../Gemfile' gem 'rack', github: 'rack/rack' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' diff --git a/gemfiles/rails_5.gemfile b/gemfiles/rails_5.gemfile deleted file mode 100644 index 894d2bd50..000000000 --- a/gemfiles/rails_5.gemfile +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# This file was generated by Appraisal - -source 'https://rubygems.org' - -gem 'rails', '~> 5.2' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' diff --git a/gemfiles/rails_6.gemfile b/gemfiles/rails_6.gemfile deleted file mode 100644 index c7b9671f2..000000000 --- a/gemfiles/rails_6.gemfile +++ /dev/null @@ -1,38 +0,0 @@ -# frozen_string_literal: true - -# This file was generated by Appraisal - -source 'https://rubygems.org' - -gem 'rails', '~> 6.0' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' diff --git a/gemfiles/rails_6_1.gemfile b/gemfiles/rails_6_1.gemfile new file mode 100644 index 000000000..edb73448c --- /dev/null +++ b/gemfiles/rails_6_1.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'mutex_m' +gem 'rails', '~> 6.1' +gem 'tzinfo-data', require: false diff --git a/gemfiles/rails_7_0.gemfile b/gemfiles/rails_7_0.gemfile new file mode 100644 index 000000000..b458a7d6f --- /dev/null +++ b/gemfiles/rails_7_0.gemfile @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'mutex_m' +gem 'rails', '~> 7.0.0' +gem 'tzinfo-data', require: false diff --git a/gemfiles/rails_7_1.gemfile b/gemfiles/rails_7_1.gemfile new file mode 100644 index 000000000..81358706c --- /dev/null +++ b/gemfiles/rails_7_1.gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rails', '~> 7.1.0' +gem 'tzinfo-data', require: false diff --git a/gemfiles/rails_7_2.gemfile b/gemfiles/rails_7_2.gemfile new file mode 100644 index 000000000..eb3d029f2 --- /dev/null +++ b/gemfiles/rails_7_2.gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rails', '~> 7.2.0' +gem 'tzinfo-data', require: false diff --git a/gemfiles/rails_8_0.gemfile b/gemfiles/rails_8_0.gemfile new file mode 100644 index 000000000..715b61502 --- /dev/null +++ b/gemfiles/rails_8_0.gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +eval_gemfile '../Gemfile' + +gem 'rails', '~> 8.0' +gem 'tzinfo-data', require: false diff --git a/gemfiles/rails_edge.gemfile b/gemfiles/rails_edge.gemfile index 24b5c5a8f..ed2aab3e1 100644 --- a/gemfiles/rails_edge.gemfile +++ b/gemfiles/rails_edge.gemfile @@ -1,38 +1,6 @@ # frozen_string_literal: true -# This file was generated by Appraisal - -source 'https://rubygems.org' +eval_gemfile '../Gemfile' gem 'rails', github: 'rails/rails' - -group :development, :test do - gem 'bundler' - gem 'hashie' - gem 'rake' - gem 'rubocop', '0.84.0' - gem 'rubocop-performance', require: false -end - -group :development do - gem 'appraisal' - gem 'benchmark-ips' - gem 'guard' - gem 'guard-rspec' - gem 'guard-rubocop' -end - -group :test do - gem 'cookiejar' - gem 'coveralls_reborn' - gem 'danger-toc', '~> 0.1.2' - gem 'grape-entity', '~> 0.6' - gem 'maruku' - gem 'mime-types' - gem 'rack-jsonp', require: 'rack/jsonp' - gem 'rack-test', '~> 1.1.0' - gem 'rspec', '~> 3.0' - gem 'ruby-grape-danger', '~> 0.1.0', require: false -end - -gemspec path: '../' +gem 'tzinfo-data', require: false diff --git a/grape.gemspec b/grape.gemspec index 6ba65bb33..5aca62670 100644 --- a/grape.gemspec +++ b/grape.gemspec @@ -1,6 +1,6 @@ # frozen_string_literal: true -$LOAD_PATH.unshift File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift File.expand_path('lib', __dir__) require 'grape/version' Gem::Specification.new do |s| @@ -14,23 +14,20 @@ Gem::Specification.new do |s| s.description = 'A Ruby framework for rapid API development with great conventions.' s.license = 'MIT' s.metadata = { - 'bug_tracker_uri' => 'https://github.com/ruby-grape/grape/issues', - 'changelog_uri' => "https://github.com/ruby-grape/grape/blob/v#{s.version}/CHANGELOG.md", + 'bug_tracker_uri' => 'https://github.com/ruby-grape/grape/issues', + 'changelog_uri' => "https://github.com/ruby-grape/grape/blob/v#{s.version}/CHANGELOG.md", 'documentation_uri' => "https://www.rubydoc.info/gems/grape/#{s.version}", - 'source_code_uri' => "https://github.com/ruby-grape/grape/tree/v#{s.version}" + 'source_code_uri' => "https://github.com/ruby-grape/grape/tree/v#{s.version}", + 'rubygems_mfa_required' => 'true' } - s.add_runtime_dependency 'activesupport' - s.add_runtime_dependency 'builder' - s.add_runtime_dependency 'dry-types', '>= 1.1' - s.add_runtime_dependency 'mustermann-grape', '~> 1.0.0' - s.add_runtime_dependency 'rack', '>= 1.3.0' - s.add_runtime_dependency 'rack-accept' + s.add_dependency 'activesupport', '>= 6.1' + s.add_dependency 'dry-types', '>= 1.1' + s.add_dependency 'mustermann-grape', '~> 1.1.0' + s.add_dependency 'rack', '>= 2' + s.add_dependency 'zeitwerk' - s.files = %w[CHANGELOG.md CONTRIBUTING.md README.md grape.png UPGRADING.md LICENSE] - s.files += %w[grape.gemspec] - s.files += Dir['lib/**/*'] - s.test_files = Dir['spec/**/*'] + s.files = Dir['lib/**/*', 'CHANGELOG.md', 'CONTRIBUTING.md', 'README.md', 'grape.png', 'UPGRADING.md', 'LICENSE', 'grape.gemspec'] s.require_paths = ['lib'] - s.required_ruby_version = '>= 2.4.0' + s.required_ruby_version = '>= 2.7.0' end diff --git a/lib/grape.rb b/lib/grape.rb index e2a0b9808..a1e6f511c 100644 --- a/lib/grape.rb +++ b/lib/grape.rb @@ -1,247 +1,85 @@ # frozen_string_literal: true require 'logger' -require 'rack' -require 'rack/builder' -require 'rack/accept' -require 'rack/auth/basic' -require 'rack/auth/digest/md5' -require 'set' +require 'active_support' +require 'active_support/concern' +require 'active_support/configurable' require 'active_support/version' -require 'active_support/core_ext/hash/indifferent_access' -require 'active_support/core_ext/object/blank' +require 'active_support/isolated_execution_state' if ActiveSupport::VERSION::MAJOR > 6 +require 'active_support/core_ext/array/conversions' require 'active_support/core_ext/array/extract_options' require 'active_support/core_ext/array/wrap' +require 'active_support/core_ext/enumerable' +require 'active_support/core_ext/hash/conversions' require 'active_support/core_ext/hash/deep_merge' -require 'active_support/core_ext/hash/reverse_merge' require 'active_support/core_ext/hash/except' +require 'active_support/core_ext/hash/indifferent_access' +require 'active_support/core_ext/hash/keys' +require 'active_support/core_ext/hash/reverse_merge' require 'active_support/core_ext/hash/slice' -require 'active_support/core_ext/hash/conversions' -require 'active_support/dependencies/autoload' +require 'active_support/core_ext/module/delegation' +require 'active_support/core_ext/object/blank' +require 'active_support/core_ext/object/deep_dup' +require 'active_support/core_ext/object/try' +require 'active_support/core_ext/object/duplicable' +require 'active_support/core_ext/string/output_safety' +require 'active_support/core_ext/string/exclude' +require 'active_support/deprecation' +require 'active_support/inflector' +require 'active_support/ordered_options' require 'active_support/notifications' -require 'i18n' - -I18n.load_path << File.expand_path('../grape/locale/en.yml', __FILE__) - -module Grape - extend ::ActiveSupport::Autoload - - eager_autoload do - autoload :API - autoload :Endpoint - - autoload :Namespace - - autoload :Path - autoload :Cookies - autoload :Validations - autoload :ErrorFormatter - autoload :Formatter - autoload :Parser - autoload :Request - autoload :Env, 'grape/util/env' - autoload :Json, 'grape/util/json' - autoload :Xml, 'grape/util/xml' - end - - module Http - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Headers - end - end - - module Exceptions - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Base - autoload :Validation - autoload :ValidationArrayErrors - autoload :ValidationErrors - autoload :MissingVendorOption - autoload :MissingMimeType - autoload :MissingOption - autoload :InvalidFormatter - autoload :InvalidVersionerOption - autoload :UnknownValidator - autoload :UnknownOptions - autoload :UnknownParameter - autoload :InvalidWithOptionForRepresent - autoload :IncompatibleOptionValues - autoload :MissingGroupTypeError, 'grape/exceptions/missing_group_type' - autoload :UnsupportedGroupTypeError, 'grape/exceptions/unsupported_group_type' - autoload :InvalidMessageBody - autoload :InvalidAcceptHeader - autoload :InvalidVersionHeader - autoload :MethodNotAllowed - autoload :InvalidResponse - end - end - - module Extensions - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :DeepMergeableHash - autoload :DeepSymbolizeHash - autoload :Hash - end - module ActiveSupport - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :HashWithIndifferentAccess - end - end - module Hashie - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Mash - end - end - end - - module Middleware - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Base - autoload :Versioner - autoload :Formatter - autoload :Error - autoload :Globals - autoload :Stack - autoload :Helpers - end - - module Auth - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Base - autoload :DSL - autoload :StrategyInfo - autoload :Strategies - end - end - - module Versioner - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Path - autoload :Header - autoload :Param - autoload :AcceptVersionHeader - end - end - end - - module Util - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :InheritableValues - autoload :StackableValues - autoload :ReverseStackableValues - autoload :InheritableSetting - autoload :StrictHashConfiguration - autoload :Registrable - end - end - - module ErrorFormatter - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Base - autoload :Json - autoload :Txt - autoload :Xml - end - end - - module Formatter - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Json - autoload :SerializableHash - autoload :Txt - autoload :Xml - end - end - - module Parser - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Json - autoload :Xml - end - end +require 'English' +require 'bigdecimal' +require 'date' +require 'dry-types' +require 'forwardable' +require 'json' +require 'mustermann/grape' +require 'pathname' +require 'rack' +require 'rack/auth/basic' +require 'rack/builder' +require 'rack/head' +require 'set' +require 'singleton' +require 'zeitwerk' - module DSL - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :API - autoload :Callbacks - autoload :Settings - autoload :Configuration - autoload :InsideRoute - autoload :Helpers - autoload :Middleware - autoload :Parameters - autoload :RequestResponse - autoload :Routing - autoload :Validations - autoload :Logger - autoload :Desc - end - end +loader = Zeitwerk::Loader.for_gem +loader.inflector.inflect( + 'api' => 'API', + 'dsl' => 'DSL' +) +railtie = "#{__dir__}/grape/railtie.rb" +loader.do_not_eager_load(railtie) +loader.setup - class API - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Helpers - end - end +I18n.load_path << File.expand_path('grape/locale/en.yml', __dir__) - module Presenters - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :Presenter - end +module Grape + include ActiveSupport::Configurable + + HTTP_SUPPORTED_METHODS = [ + Rack::GET, + Rack::POST, + Rack::PUT, + Rack::PATCH, + Rack::DELETE, + Rack::HEAD, + Rack::OPTIONS + ].freeze + + def self.deprecator + @deprecator ||= ActiveSupport::Deprecation.new('2.0', 'Grape') end - module ServeStream - extend ::ActiveSupport::Autoload - eager_autoload do - autoload :FileBody - autoload :SendfileResponse - autoload :StreamResponse - end + configure do |config| + config.param_builder = :hash_with_indifferent_access + config.compile_methods! end end -require 'grape/config' -require 'grape/content_types' - -require 'grape/util/lazy_value' -require 'grape/util/lazy_block' -require 'grape/util/endpoint_configuration' - -require 'grape/validations/validators/base' -require 'grape/validations/attributes_iterator' -require 'grape/validations/single_attribute_iterator' -require 'grape/validations/multiple_attributes_iterator' -require 'grape/validations/validators/allow_blank' -require 'grape/validations/validators/as' -require 'grape/validations/validators/at_least_one_of' -require 'grape/validations/validators/coerce' -require 'grape/validations/validators/default' -require 'grape/validations/validators/exactly_one_of' -require 'grape/validations/validators/mutual_exclusion' -require 'grape/validations/validators/presence' -require 'grape/validations/validators/regexp' -require 'grape/validations/validators/same_as' -require 'grape/validations/validators/values' -require 'grape/validations/validators/except_values' -require 'grape/validations/params_scope' -require 'grape/validations/validators/all_or_none' -require 'grape/validations/types' -require 'grape/validations/validator_factory' - -require 'grape/version' +# https://api.rubyonrails.org/classes/ActiveSupport/Deprecation.html +# adding Grape.deprecator to Rails App if any +require 'grape/railtie' if defined?(Rails::Railtie) && ActiveSupport.gem_version >= Gem::Version.new('7.1') +loader.eager_load diff --git a/lib/grape/api.rb b/lib/grape/api.rb index 45dd76c74..8ddc207ef 100644 --- a/lib/grape/api.rb +++ b/lib/grape/api.rb @@ -1,29 +1,45 @@ # frozen_string_literal: true -require 'grape/router' -require 'grape/api/instance' - module Grape # The API class is the primary entry point for creating Grape APIs. Users # should subclass this class in order to build an API. class API # Class methods that we want to call on the API rather than on the API object - NON_OVERRIDABLE = (Class.new.methods + %i[call call! configuration compile! inherited]).freeze + NON_OVERRIDABLE = %i[call call! configuration compile! inherited].freeze + + class Boolean + def self.build(val) + return nil if val != true && val != false + + new + end + end + + class Instance + Boolean = Grape::API::Boolean + end class << self + extend Forwardable attr_accessor :base_instance, :instances - # Rather than initializing an object of type Grape::API, create an object of type Instance - def new(*args, &block) - base_instance.new(*args, &block) - end + delegate_missing_to :base_instance + def_delegators :base_instance, :new, :configuration + + # This is the interface point between Rack and Grape; it accepts a request + # from Rack and ultimately returns an array of three values: the status, + # the headers, and the body. See [the rack specification] + # (http://www.rubydoc.info/github/rack/rack/master/file/SPEC) for more. + # NOTE: This will only be called on an API directly mounted on RACK + def_delegators :instance_for_rack, :call, :compile! # When inherited, will create a list of all instances (times the API was mounted) # It will listen to the setup required to mount that endpoint, and replicate it on any new instance - def inherited(api, base_instance_parent = Grape::API::Instance) - api.initial_setup(base_instance_parent) + def inherited(api) + super + + api.initial_setup(self == Grape::API ? Grape::API::Instance : @base_instance) api.override_all_methods! - make_inheritable(api) end # Initialize the instance variables on the remountable class, and the base_instance @@ -37,9 +53,9 @@ def initial_setup(base_instance_parent) # Redefines all methods so that are forwarded to add_setup and be recorded def override_all_methods! - (base_instance.methods - NON_OVERRIDABLE).each do |method_override| + (base_instance.methods - Class.methods - NON_OVERRIDABLE).each do |method_override| define_singleton_method(method_override) do |*args, &block| - add_setup(method_override, *args, &block) + add_setup(method: method_override, args: args, block: block) end end end @@ -59,77 +75,28 @@ def configure end end - # This is the interface point between Rack and Grape; it accepts a request - # from Rack and ultimately returns an array of three values: the status, - # the headers, and the body. See [the rack specification] - # (http://www.rubydoc.info/github/rack/rack/master/file/SPEC) for more. - # NOTE: This will only be called on an API directly mounted on RACK - def call(*args, &block) - instance_for_rack.call(*args, &block) - end - - # Allows an API to itself be inheritable: - def make_inheritable(api) - # When a child API inherits from a parent API. - def api.inherited(child_api) - # The instances of the child API inherit from the instances of the parent API - Grape::API.inherited(child_api, base_instance) - end - end - - # Alleviates problems with autoloading by tring to search for the constant - def const_missing(*args) - if base_instance.const_defined?(*args) - base_instance.const_get(*args) - else - super - end - end - # The remountable class can have a configuration hash to provide some dynamic class-level variables. - # For instance, a descripcion could be done using: `desc configuration[:description]` if it may vary + # For instance, a description could be done using: `desc configuration[:description]` if it may vary # depending on where the endpoint is mounted. Use with care, if you find yourself using configuration # too much, you may actually want to provide a new API rather than remount it. - def mount_instance(**opts) - instance = Class.new(@base_parent) - instance.configuration = Grape::Util::EndpointConfiguration.new(opts[:configuration] || {}) - instance.base = self - replay_setup_on(instance) - instance + def mount_instance(configuration: nil) + Class.new(@base_parent).tap do |instance| + instance.configuration = Grape::Util::EndpointConfiguration.new(configuration || {}) + instance.base = self + replay_setup_on(instance) + end end + private + # Replays the set up to produce an API as defined in this class, can be called # on classes that inherit from Grape::API def replay_setup_on(instance) @setup.each do |setup_step| - replay_step_on(instance, setup_step) - end - end - - def respond_to?(method, include_private = false) - super(method, include_private) || base_instance.respond_to?(method, include_private) - end - - def respond_to_missing?(method, include_private = false) - base_instance.respond_to?(method, include_private) - end - - def method_missing(method, *args, &block) - # If there's a missing method, it may be defined on the base_instance instead. - if respond_to_missing?(method) - base_instance.send(method, *args, &block) - else - super + replay_step_on(instance, **setup_step) end end - def compile! - require 'grape/eager_load' - instance_for_rack.compile! # See API::Instance.compile! - end - - private - def instance_for_rack if never_mounted? base_instance @@ -139,20 +106,35 @@ def instance_for_rack end # Adds a new stage to the set up require to get a Grape::API up and running - def add_setup(method, *args, &block) - setup_step = { method: method, args: args, block: block } - @setup << setup_step + def add_setup(step) + @setup << step last_response = nil @instances.each do |instance| - last_response = replay_step_on(instance, setup_step) + last_response = replay_step_on(instance, **step) end + + refresh_mount_step if step[:method] != :mount last_response end - def replay_step_on(instance, setup_step) - return if skip_immediate_run?(instance, setup_step[:args]) - args = evaluate_arguments(instance.configuration, *setup_step[:args]) - response = instance.send(setup_step[:method], *args, &setup_step[:block]) + # Updating all previously mounted classes in the case that new methods have been executed. + def refresh_mount_step + @setup.each do |setup_step| + next if setup_step[:method] != :mount + + refresh_mount_step = setup_step.merge(method: :refresh_mounted_api) + @setup << refresh_mount_step + @instances.each do |instance| + replay_step_on(instance, **refresh_mount_step) + end + end + end + + def replay_step_on(instance, method:, args:, block:) + return if skip_immediate_run?(instance, args) + + eval_args = evaluate_arguments(instance.configuration, *args) + response = instance.send(method, *eval_args, &block) if skip_immediate_run?(instance, [response]) response else @@ -167,12 +149,12 @@ def skip_immediate_run?(instance, args) end def any_lazy?(args) - args.any? { |argument| argument.respond_to?(:lazy?) && argument.lazy? } + args.any? { |argument| argument.try(:lazy?) } end def evaluate_arguments(configuration, *args) args.map do |argument| - if argument.respond_to?(:lazy?) && argument.lazy? + if argument.try(:lazy?) argument.evaluate_from(configuration) elsif argument.is_a?(Hash) argument.transform_values { |value| evaluate_arguments(configuration, value).first } diff --git a/lib/grape/api/instance.rb b/lib/grape/api/instance.rb index e8f8d85fb..aa24263a6 100644 --- a/lib/grape/api/instance.rb +++ b/lib/grape/api/instance.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/router' - module Grape class API # The API Instance class, is the engine behind Grape::API. Each class that inherits @@ -10,12 +8,11 @@ class Instance include Grape::DSL::API class << self - attr_reader :instance - attr_reader :base + attr_reader :instance, :base attr_accessor :configuration def given(conditional_option, &block) - evaluate_as_instance_with_configuration(block, lazy: true) if conditional_option && block_given? + evaluate_as_instance_with_configuration(block, lazy: true) if conditional_option && block end def mounted(&block) @@ -28,7 +25,7 @@ def base=(grape_api) end def to_s - (base && base.to_s) || super + base&.to_s || super end def base_instance? @@ -49,7 +46,7 @@ def reset! # Parses the API's definition and compiles it into an instance of # Grape::API. def compile - @instance ||= new + @instance ||= new # rubocop:disable Naming/MemoizedInstanceVariableName end # Wipe the compiled API so we can recompile after changes were made. @@ -82,6 +79,7 @@ def cascade(value = nil) def compile! return if instance + LOCK.synchronize { compile unless instance } end @@ -101,9 +99,9 @@ def prepare_routes # block passed in. Allows for simple 'before' setups # of settings stack pushes. def nest(*blocks, &block) - blocks.reject!(&:nil?) + blocks.compact! if blocks.any? - evaluate_as_instance_with_configuration(block) if block_given? + evaluate_as_instance_with_configuration(block) if block blocks.each { |b| evaluate_as_instance_with_configuration(b) } reset_validations! else @@ -112,11 +110,9 @@ def nest(*blocks, &block) end def evaluate_as_instance_with_configuration(block, lazy: false) - lazy_block = Grape::Util::LazyBlock.new do |configuration| + lazy_block = Grape::Util::Lazy::Block.new do |configuration| value_for_configuration = configuration - if value_for_configuration.respond_to?(:lazy?) && value_for_configuration.lazy? - self.configuration = value_for_configuration.evaluate - end + self.configuration = value_for_configuration.evaluate if value_for_configuration.try(:lazy?) response = instance_eval(&block) self.configuration = value_for_configuration response @@ -129,6 +125,7 @@ def evaluate_as_instance_with_configuration(block, lazy: false) end def inherited(subclass) + super subclass.reset! subclass.logger = logger.clone end @@ -164,9 +161,13 @@ def initialize # Handle a request. See Rack documentation for what `env` is. def call(env) - result = @router.call(env) - result[1].delete(Grape::Http::Headers::X_CASCADE) unless cascade? - result + status, headers, response = @router.call(env) + unless cascade? + headers = Grape::Util::Header.new.merge(headers) + headers.delete('X-Cascade') + end + + [status, headers, response] end # Some requests may return a HTTP 404 error if grape cannot find a matching @@ -179,7 +180,8 @@ def call(env) # X-Cascade. Default :cascade is true. def cascade? return self.class.namespace_inheritable(:cascade) if self.class.inheritable_setting.namespace_inheritable.key?(:cascade) - return self.class.namespace_inheritable(:version_options)[:cascade] if self.class.namespace_inheritable(:version_options) && self.class.namespace_inheritable(:version_options).key?(:cascade) + return self.class.namespace_inheritable(:version_options)[:cascade] if self.class.namespace_inheritable(:version_options)&.key?(:cascade) + true end @@ -192,91 +194,52 @@ def cascade? # will return an HTTP 405 response for any HTTP method that the resource # cannot handle. def add_head_not_allowed_methods_and_options_methods - versioned_route_configs = collect_route_config_per_pattern # The paths we collected are prepared (cf. Path#prepare), so they # contain already versioning information when using path versioning. + all_routes = self.class.endpoints.map(&:routes).flatten + # Disable versioning so adding a route won't prepend versioning # informations again. - without_root_prefix do - without_versioning do - versioned_route_configs.each do |config| - allowed_methods = config[:methods].dup - - unless self.class.namespace_inheritable(:do_not_route_head) - allowed_methods |= [Grape::Http::Headers::HEAD] if allowed_methods.include?(Grape::Http::Headers::GET) - end + without_root_prefix_and_versioning { collect_route_config_per_pattern(all_routes) } + end - allow_header = (self.class.namespace_inheritable(:do_not_route_options) ? allowed_methods : [Grape::Http::Headers::OPTIONS] | allowed_methods) + def collect_route_config_per_pattern(all_routes) + routes_by_regexp = all_routes.group_by(&:pattern_regexp) - unless self.class.namespace_inheritable(:do_not_route_options) || allowed_methods.include?(Grape::Http::Headers::OPTIONS) - config[:endpoint].options[:options_route_enabled] = true - end + # Build the configuration based on the first endpoint and the collection of methods supported. + routes_by_regexp.each_value do |routes| + last_route = routes.last # Most of the configuration is taken from the last endpoint + next if routes.any? { |route| route.request_method == '*' } - attributes = config.merge(allowed_methods: allowed_methods, allow_header: allow_header) - generate_not_allowed_method(config[:pattern], **attributes) - end - end - end - end + allowed_methods = routes.map(&:request_method) + allowed_methods |= [Rack::HEAD] if !self.class.namespace_inheritable(:do_not_route_head) && allowed_methods.include?(Rack::GET) - def collect_route_config_per_pattern - all_routes = self.class.endpoints.map(&:routes).flatten - routes_by_regexp = all_routes.group_by { |route| route.pattern.to_regexp } + allow_header = self.class.namespace_inheritable(:do_not_route_options) ? allowed_methods : [Rack::OPTIONS] | allowed_methods + last_route.app.options[:options_route_enabled] = true unless self.class.namespace_inheritable(:do_not_route_options) || allowed_methods.include?(Rack::OPTIONS) - # Build the configuration based on the first endpoint and the collection of methods supported. - routes_by_regexp.values.map do |routes| - last_route = routes.last # Most of the configuration is taken from the last endpoint - matching_wildchar = routes.any? { |route| route.request_method == '*' } - { - options: {}, - pattern: last_route.pattern, - requirements: last_route.requirements, - path: last_route.origin, - endpoint: last_route.app, - methods: matching_wildchar ? Grape::Http::Headers::SUPPORTED_METHODS : routes.map(&:request_method) - } + @router.associate_routes(last_route.pattern, { + endpoint: last_route.app, + allow_header: allow_header + }) end end - # Generate a route that returns an HTTP 405 response for a user defined - # path on methods not specified - def generate_not_allowed_method(pattern, allowed_methods: [], **attributes) - supported_methods = - if self.class.namespace_inheritable(:do_not_route_options) - Grape::Http::Headers::SUPPORTED_METHODS - else - Grape::Http::Headers::SUPPORTED_METHODS_WITHOUT_OPTIONS - end - not_allowed_methods = supported_methods - allowed_methods - return if not_allowed_methods.empty? - @router.associate_routes(pattern, not_allowed_methods: not_allowed_methods, **attributes) - end - # Allows definition of endpoints that ignore the versioning configuration # used by the rest of your API. - def without_versioning(&_block) + def without_root_prefix_and_versioning old_version = self.class.namespace_inheritable(:version) old_version_options = self.class.namespace_inheritable(:version_options) + old_root_prefix = self.class.namespace_inheritable(:root_prefix) self.class.namespace_inheritable_to_nil(:version) self.class.namespace_inheritable_to_nil(:version_options) + self.class.namespace_inheritable_to_nil(:root_prefix) yield self.class.namespace_inheritable(:version, old_version) self.class.namespace_inheritable(:version_options, old_version_options) - end - - # Allows definition of endpoints that ignore the root prefix used by the - # rest of your API. - def without_root_prefix(&_block) - old_prefix = self.class.namespace_inheritable(:root_prefix) - - self.class.namespace_inheritable_to_nil(:root_prefix) - - yield - - self.class.namespace_inheritable(:root_prefix, old_prefix) + self.class.namespace_inheritable(:root_prefix, old_root_prefix) end end end diff --git a/lib/grape/config.rb b/lib/grape/config.rb deleted file mode 100644 index 6d76c06b1..000000000 --- a/lib/grape/config.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Config - class Configuration - ATTRIBUTES = %i[ - param_builder - ].freeze - - attr_accessor(*ATTRIBUTES) - - def initialize - reset - end - - def reset - self.param_builder = Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder - end - end - - def self.extended(base) - def base.configure - block_given? ? yield(config) : config - end - - def base.config - @configuration ||= Grape::Config::Configuration.new - end - end - end -end - -Grape.extend Grape::Config -Grape.config.reset diff --git a/lib/grape/content_types.rb b/lib/grape/content_types.rb index 2c19f9731..336948b9b 100644 --- a/lib/grape/content_types.rb +++ b/lib/grape/content_types.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true -require 'grape/util/registrable' - module Grape module ContentTypes - extend Util::Registrable + module_function # Content types are listed in order of preference. - CONTENT_TYPES = { + DEFAULTS = { xml: 'application/xml', serializable_hash: 'application/json', json: 'application/json', @@ -15,19 +13,18 @@ module ContentTypes txt: 'text/plain' }.freeze - class << self - def content_types_for_settings(settings) - return if settings.blank? + MIME_TYPES = Grape::ContentTypes::DEFAULTS.except(:serializable_hash).invert.freeze - settings.each_with_object({}) { |value, result| result.merge!(value) } - end + def content_types_for(from_settings) + from_settings.presence || DEFAULTS + end + + def mime_types_for(from_settings) + return MIME_TYPES if from_settings == Grape::ContentTypes::DEFAULTS - def content_types_for(from_settings) - if from_settings.present? - from_settings - else - Grape::ContentTypes::CONTENT_TYPES.merge(default_elements) - end + from_settings.each_with_object({}) do |(k, v), types_without_params| + # remove optional parameter + types_without_params[v.split(';', 2).first] = k end end end diff --git a/lib/grape/cookies.rb b/lib/grape/cookies.rb index 6e37c6d32..039bf4c4f 100644 --- a/lib/grape/cookies.rb +++ b/lib/grape/cookies.rb @@ -2,40 +2,49 @@ module Grape class Cookies - def initialize - @cookies = {} - @send_cookies = {} - end + extend Forwardable - def read(request) - request.cookies.each do |name, value| - @cookies[name.to_s] = value - end + DELETED_COOKIES_ATTRS = { + max_age: '0', + value: '', + expires: Time.at(0) + }.freeze + + def_delegators :cookies, :[], :each + + def initialize(rack_cookies) + @cookies = rack_cookies + @send_cookies = nil end - def write(header) - @cookies.select { |key, _value| @send_cookies[key] == true }.each do |name, value| - cookie_value = value.is_a?(Hash) ? value : { value: value } - Rack::Utils.set_cookie_header! header, name, cookie_value + def response_cookies + return unless @send_cookies + + send_cookies.each do |name| + yield name, cookies[name] end end - def [](name) - @cookies[name.to_s] + def []=(name, value) + cookies[name] = value + send_cookies << name end - def []=(name, value) - @cookies[name.to_s] = value - @send_cookies[name.to_s] = true + # see https://github.com/rack/rack/blob/main/lib/rack/utils.rb#L338-L340 + def delete(name, **opts) + self.[]=(name, opts.merge(DELETED_COOKIES_ATTRS)) end - def each(&block) - @cookies.each(&block) + private + + def cookies + return @cookies unless @cookies.is_a?(Proc) + + @cookies = @cookies.call.with_indifferent_access end - def delete(name, **opts) - options = opts.merge(value: 'deleted', expires: Time.at(0)) - self.[]=(name, options) + def send_cookies + @send_cookies ||= Set.new end end end diff --git a/lib/grape/dry_types.rb b/lib/grape/dry_types.rb new file mode 100644 index 000000000..5f1bc3cde --- /dev/null +++ b/lib/grape/dry_types.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Grape + module DryTypes + # Call +Dry.Types()+ to add all registered types to +DryTypes+ which is + # a container in this case. Check documentation for more information + # https://dry-rb.org/gems/dry-types/1.2/getting-started/ + include Dry.Types() + end +end diff --git a/lib/grape/dsl/api.rb b/lib/grape/dsl/api.rb index 0543cac54..09126a5fb 100644 --- a/lib/grape/dsl/api.rb +++ b/lib/grape/dsl/api.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module API diff --git a/lib/grape/dsl/callbacks.rb b/lib/grape/dsl/callbacks.rb index ae6049aa2..43d4b18dc 100644 --- a/lib/grape/dsl/callbacks.rb +++ b/lib/grape/dsl/callbacks.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL # Blocks can be executed before or after every API call, using `before`, `after`, @@ -59,7 +57,7 @@ def after(&block) # end # end # - # This will make sure that the ApiLogger is opened and close around every + # This will make sure that the ApiLogger is opened and closed around every # request # @param ensured_block [Proc] The block to be executed after every api_call def finally(&block) diff --git a/lib/grape/dsl/configuration.rb b/lib/grape/dsl/configuration.rb index f33af3225..6abf75968 100644 --- a/lib/grape/dsl/configuration.rb +++ b/lib/grape/dsl/configuration.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module Configuration diff --git a/lib/grape/dsl/desc.rb b/lib/grape/dsl/desc.rb index 8e94750c7..2d3155026 100644 --- a/lib/grape/dsl/desc.rb +++ b/lib/grape/dsl/desc.rb @@ -5,6 +5,27 @@ module DSL module Desc include Grape::DSL::Settings + ROUTE_ATTRIBUTES = %i[ + body_name + consumes + default + deprecated + description + detail + entity + headers + hidden + http_codes + is_array + named + nickname + params + produces + security + summary + tags + ].freeze + # Add a description to the next namespace or function. # @param description [String] descriptive string for this endpoint # or namespace @@ -49,74 +70,29 @@ module Desc # # ... # end # - def desc(description, options = {}, &config_block) - if block_given? - endpoint_configuration = if defined?(configuration) - # When the instance is mounted - the configuration is executed on mount time - if configuration.respond_to?(:evaluate) - configuration.evaluate - # Within `given` or `mounted blocks` the configuration is already evaluated - elsif configuration.is_a?(Hash) - configuration - end - end - endpoint_configuration ||= {} - config_class = desc_container(endpoint_configuration) - - config_class.configure do - description description - end + def desc(description, options = nil, &config_block) + opts = + if config_block + desc_container(endpoint_configuration).then do |config_class| + config_class.configure do + description(description) + end - config_class.configure(&config_block) - unless options.empty? - warn '[DEPRECATION] Passing a options hash and a block to `desc` is deprecated. Move all hash options to block.' + config_class.configure(&config_block) + config_class.settings + end + else + options&.merge(description: description) || { description: description } end - options = config_class.settings - else - options = options.merge(description: description) - end - - namespace_setting :description, options - route_setting :description, options - end - - def description_field(field, value = nil) - description = route_setting(:description) - if value - description ||= route_setting(:description, {}) - description[field] = value - elsif description - description[field] - end - end - def unset_description_field(field) - description = route_setting(:description) - description.delete(field) if description + namespace_setting :description, opts + route_setting :description, opts end # Returns an object which configures itself via an instance-context DSL. def desc_container(endpoint_configuration) Module.new do - include Grape::Util::StrictHashConfiguration.module( - :summary, - :description, - :detail, - :params, - :entity, - :http_codes, - :named, - :body_name, - :headers, - :hidden, - :deprecated, - :is_array, - :nickname, - :produces, - :consumes, - :security, - :tags - ) + include Grape::Util::StrictHashConfiguration.module(*ROUTE_ATTRIBUTES) config_context.define_singleton_method(:configuration) do endpoint_configuration end @@ -130,6 +106,19 @@ def config_context.failure(*args) end end end + + private + + def endpoint_configuration + return {} unless defined?(configuration) + + if configuration.respond_to?(:evaluate) + configuration.evaluate + # Within `given` or `mounted blocks` the configuration is already evaluated + elsif configuration.is_a?(Hash) + configuration + end + end end end end diff --git a/lib/grape/dsl/headers.rb b/lib/grape/dsl/headers.rb index c3c7bc3e8..4c193364f 100644 --- a/lib/grape/dsl/headers.rb +++ b/lib/grape/dsl/headers.rb @@ -3,13 +3,16 @@ module Grape module DSL module Headers - # Set an individual header or retrieve - # all headers that have been set. + # This method has four responsibilities: + # 1. Set a specifc header value by key + # 2. Retrieve a specifc header value by key + # 3. Retrieve all headers that have been set + # 4. Delete a specifc header key-value pair def header(key = nil, val = nil) if key - val ? header[key.to_s] = val : header.delete(key.to_s) + val ? header[key] = val : header.delete(key) else - @header ||= {} + @header ||= Grape::Util::Header.new end end alias headers header diff --git a/lib/grape/dsl/helpers.rb b/lib/grape/dsl/helpers.rb index bbd2ed3ba..875fee511 100644 --- a/lib/grape/dsl/helpers.rb +++ b/lib/grape/dsl/helpers.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module Helpers @@ -35,18 +33,22 @@ module ClassMethods # end # def helpers(*new_modules, &block) - include_new_modules(new_modules) if new_modules.any? - include_block(block) if block_given? - include_all_in_scope if !block_given? && new_modules.empty? + include_new_modules(new_modules) + include_block(block) + include_all_in_scope if !block && new_modules.empty? end protected def include_new_modules(modules) + return if modules.empty? + modules.each { |mod| make_inclusion(mod) } end def include_block(block) + return unless block + Module.new.tap do |mod| make_inclusion(mod) { mod.class_eval(&block) } end @@ -60,19 +62,20 @@ def make_inclusion(mod, &block) def include_all_in_scope Module.new.tap do |mod| - namespace_stackable(:helpers).each { |mod_to_include| mod.send :include, mod_to_include } + namespace_stackable(:helpers).each { |mod_to_include| mod.include mod_to_include } change! end end def define_boolean_in_mod(mod) return if defined? mod::Boolean - mod.const_set('Boolean', Grape::API::Boolean) + + mod.const_set(:Boolean, Grape::API::Boolean) end - def inject_api_helpers_to_mod(mod, &_block) + def inject_api_helpers_to_mod(mod, &block) mod.extend(BaseHelper) unless mod.is_a?(BaseHelper) - yield if block_given? + yield if block mod.api_changed(self) end end @@ -81,6 +84,7 @@ def inject_api_helpers_to_mod(mod, &_block) # to provide some API-specific functionality. module BaseHelper attr_accessor :api + def params(name, &block) @named_params ||= {} @named_params[name] = block @@ -94,7 +98,8 @@ def api_changed(new_api) protected def process_named_params - return unless instance_variable_defined?(:@named_params) && @named_params && @named_params.any? + return if @named_params.blank? + api.namespace_stackable(:named_params, @named_params) end end diff --git a/lib/grape/dsl/inside_route.rb b/lib/grape/dsl/inside_route.rb index dc8f9f05c..2d7568afa 100644 --- a/lib/grape/dsl/inside_route.rb +++ b/lib/grape/dsl/inside_route.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' -require 'grape/dsl/headers' - module Grape module DSL module InsideRoute @@ -29,14 +26,20 @@ def self.post_filter_methods(type) # has completed module PostBeforeFilter def declared(passed_params, options = {}, declared_params = nil, params_nested_path = []) - options = options.reverse_merge(include_missing: true, include_parent_namespaces: true) - declared_params ||= optioned_declared_params(**options) + options.reverse_merge!(include_missing: true, include_parent_namespaces: true, evaluate_given: false) + declared_params ||= optioned_declared_params(options[:include_parent_namespaces]) - 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 @@ -48,81 +51,77 @@ def declared_array(passed_params, options, declared_params, params_nested_path) end def declared_hash(passed_params, options, declared_params, params_nested_path) - declared_params.each_with_object(passed_params.class.new) do |declared_param, memo| - if declared_param.is_a?(Hash) - declared_param.each_pair do |declared_parent_param, declared_children_params| - params_nested_path_dup = params_nested_path.dup - params_nested_path_dup << declared_parent_param.to_s - next unless options[:include_missing] || passed_params.key?(declared_parent_param) - - passed_children_params = passed_params[declared_parent_param] || passed_params.class.new - memo_key = optioned_param_key(declared_parent_param, options) - - memo[memo_key] = handle_passed_param(passed_children_params, params_nested_path_dup) do - declared(passed_children_params, options, declared_children_params, params_nested_path_dup) - end - end - else - # If it is not a Hash then it does not have children. - # Find its value or set it to nil. - has_renaming = route_setting(:renamed_params) && route_setting(:renamed_params).find { |current| current[declared_param] } - param_renaming = has_renaming[declared_param] if has_renaming + declared_params.each_with_object(passed_params.class.new) do |declared_param_attr, memo| + next if options[:evaluate_given] && !declared_param_attr.scope.attr_meets_dependency?(passed_params) - next unless options[:include_missing] || passed_params.key?(declared_param) || (param_renaming && passed_params.key?(param_renaming)) + declared_hash_attr(passed_params, options, declared_param_attr.key, params_nested_path, memo) + end + end + + def declared_hash_attr(passed_params, options, declared_param, params_nested_path, memo) + renamed_params = route_setting(:renamed_params) || {} + if declared_param.is_a?(Hash) + declared_param.each_pair do |declared_parent_param, declared_children_params| + params_nested_path_dup = params_nested_path.dup + params_nested_path_dup << declared_parent_param.to_s + next unless options[:include_missing] || passed_params.key?(declared_parent_param) + + rename_path = params_nested_path + [declared_parent_param.to_s] + renamed_param_name = renamed_params[rename_path] - if param_renaming - memo[optioned_param_key(param_renaming, options)] = passed_params[param_renaming] - else - memo[optioned_param_key(declared_param, options)] = passed_params[declared_param] + memo_key = optioned_param_key(renamed_param_name || declared_parent_param, options) + passed_children_params = passed_params[declared_parent_param] || passed_params.class.new + + memo[memo_key] = handle_passed_param(params_nested_path_dup, passed_children_params.any?) do + declared(passed_children_params, options, declared_children_params, params_nested_path_dup) end end + else + # If it is not a Hash then it does not have children. + # Find its value or set it to nil. + return unless options[:include_missing] || passed_params.try(:key?, declared_param) + + rename_path = params_nested_path + [declared_param.to_s] + renamed_param_name = renamed_params[rename_path] + + memo_key = optioned_param_key(renamed_param_name || declared_param, options) + passed_param = passed_params[declared_param] + + params_nested_path_dup = params_nested_path.dup + params_nested_path_dup << declared_param.to_s + memo[memo_key] = passed_param || handle_passed_param(params_nested_path_dup) do + passed_param + end end end - def handle_passed_param(passed_children_params, params_nested_path, &_block) - if should_be_empty_hash?(passed_children_params, params_nested_path) + def handle_passed_param(params_nested_path, has_passed_children = false, &_block) + return yield if has_passed_children + + key = params_nested_path[0] + key += "[#{params_nested_path[1..].join('][')}]" if params_nested_path.size > 1 + + route_options_params = options[:route_options][:params] || {} + type = route_options_params.dig(key, :type) + has_children = route_options_params.keys.any? { |k| k != key && k.start_with?("#{key}[") } + + if type == 'Hash' && !has_children {} - elsif should_be_empty_array?(passed_children_params, params_nested_path) + elsif type == 'Array' || (type&.start_with?('[') && type.exclude?(',')) [] + elsif type == 'Set' || type&.start_with?('# 1 - key - end - - def optioned_declared_params(**options) - declared_params = if options[:include_parent_namespaces] + def optioned_declared_params(include_parent_namespaces) + declared_params = if include_parent_namespaces # Declared params including parent namespaces route_setting(:declared_params) else @@ -131,6 +130,7 @@ def optioned_declared_params(**options) end raise ArgumentError, 'Tried to filter for declared parameters but none exist.' unless declared_params + declared_params end end @@ -163,26 +163,50 @@ def configuration # end user with the specified message. # # @param message [String] The message to display. - # @param status [Integer] the HTTP Status Code. Defaults to default_error_status, 500 if not set. + # @param status [Integer] The HTTP Status Code. Defaults to default_error_status, 500 if not set. # @param additional_headers [Hash] Addtional headers for the response. - def error!(message, status = nil, additional_headers = nil) - self.status(status || namespace_inheritable(:default_error_status)) + # @param backtrace [Array] The backtrace of the exception that caused the error. + # @param original_exception [Exception] The original exception that caused the error. + def error!(message, status = nil, additional_headers = nil, backtrace = nil, original_exception = nil) + status = self.status(status || namespace_inheritable(:default_error_status)) headers = additional_headers.present? ? header.merge(additional_headers) : header - throw :error, message: message, status: self.status, headers: headers + throw :error, + message: message, + status: status, + headers: headers, + backtrace: backtrace, + original_exception: original_exception + end + + # Creates a Rack response based on the provided message, status, and headers. + # The content type in the headers is set to the default content type unless provided. + # The message is HTML-escaped if the content type is 'text/html'. + # + # @param message [String] The content of the response. + # @param status [Integer] The HTTP status code. + # @params headers [Hash] (optional) Headers for the response + # (default: {Rack::CONTENT_TYPE => content_type}). + # + # Returns: + # A Rack::Response object containing the specified message, status, and headers. + # + def rack_response(message, status = 200, headers = { Rack::CONTENT_TYPE => content_type }) + Grape.deprecator.warn('The rack_response method has been deprecated, use error! instead.') + message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html' + Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), headers) end # Redirect to a new url. # # @param url [String] The url to be redirect. - # @param options [Hash] The options used when redirect. - # :permanent, default false. - # :body, default a short message including the URL. - def redirect(url, permanent: false, body: nil, **_options) + # @param permanent [Boolean] default false. + # @param body default a short message including the URL. + def redirect(url, permanent: false, body: nil) body_message = body if permanent status 301 body_message ||= "This resource has been moved permanently to #{url}." - elsif env[Grape::Http::Headers::HTTP_VERSION] == 'HTTP/1.1' && request.request_method.to_s.upcase != Grape::Http::Headers::GET + elsif http_version == 'HTTP/1.1' && !request.get? status 303 body_message ||= "An alternate resource is located at #{url}." else @@ -201,15 +225,16 @@ def status(status = nil) case status when Symbol raise ArgumentError, "Status code :#{status} is invalid." unless Rack::Utils::SYMBOL_TO_STATUS_CODE.key?(status) + @status = Rack::Utils.status_code(status) when Integer @status = status when nil return @status if instance_variable_defined?(:@status) && @status - case request.request_method.to_s.upcase - when Grape::Http::Headers::POST + + if request.post? 201 - when Grape::Http::Headers::DELETE + elsif request.delete? if instance_variable_defined?(:@body) && @body.present? 200 else @@ -226,24 +251,12 @@ def status(status = nil) # Set response content-type def content_type(val = nil) if val - header(Grape::Http::Headers::CONTENT_TYPE, val) + header(Rack::CONTENT_TYPE, val) else - header[Grape::Http::Headers::CONTENT_TYPE] + header[Rack::CONTENT_TYPE] end end - # Set or get a cookie - # - # @example - # cookies[:mycookie] = 'mycookie val' - # cookies['mycookie-string'] = 'mycookie string val' - # cookies[:more] = { value: '123', expires: Time.at(0) } - # cookies.delete :more - # - def cookies - @cookies ||= Cookies.new - end - # Allows you to define the response body as something other than the # return value. # @@ -279,20 +292,6 @@ def return_no_content body false end - # Deprecated method to send files to the client. Use `sendfile` or `stream` - def file(value = nil) - if value.is_a?(String) - warn '[DEPRECATION] Use sendfile or stream to send files.' - sendfile(value) - elsif !value.is_a?(NilClass) - warn '[DEPRECATION] Use stream to use a Stream object.' - stream(value) - else - warn '[DEPRECATION] Use sendfile or stream to send files.' - sendfile - end - end - # Allows you to send a file to the client via sendfile. # # @example @@ -330,9 +329,9 @@ def sendfile(value = nil) def stream(value = nil) return if value.nil? && @stream.nil? - header 'Content-Length', nil + header Rack::CONTENT_LENGTH, nil header 'Transfer-Encoding', nil - header 'Cache-Control', 'no-cache' # Skips ETag generation (reading the response up front) + header Rack::CACHE_CONTROL, 'no-cache' # Skips ETag generation (reading the response up front) if value.is_a?(String) file_body = Grape::ServeStream::FileBody.new(value) @stream = Grape::ServeStream::StreamResponse.new(file_body) @@ -383,6 +382,7 @@ def present(*args) representation = (body || {}).merge(key => representation) elsif entity_class.present? && body raise ArgumentError, "Representation of type #{representation.class} cannot be merged." unless representation.respond_to?(:merge) + representation = body.merge(representation) end @@ -413,7 +413,7 @@ def entity_class_for_obj(object, options) entity_class = options.delete(:with) if entity_class.nil? - # entity class not explicitely defined, auto-detect from relation#klass or first object in the collection + # entity class not explicitly defined, auto-detect from relation#klass or first object in the collection object_class = if object.respond_to?(:klass) object.klass else @@ -434,8 +434,20 @@ def entity_class_for_obj(object, options) # the given entity_class. def entity_representation_for(entity_class, object, options) embeds = { env: env } - embeds[:version] = env[Grape::Env::API_VERSION] if env[Grape::Env::API_VERSION] - entity_class.represent(object, embeds.merge(options)) + embeds[:version] = env[Grape::Env::API_VERSION] if env.key?(Grape::Env::API_VERSION) + entity_class.represent(object, **embeds, **options) + end + + def http_version + env.fetch('HTTP_VERSION') { env[Rack::SERVER_PROTOCOL] } + end + + def api_format(format) + env[Grape::Env::API_FORMAT] = format + end + + def context + self end end end diff --git a/lib/grape/dsl/middleware.rb b/lib/grape/dsl/middleware.rb index f07f310b1..07e6f9fe7 100644 --- a/lib/grape/dsl/middleware.rb +++ b/lib/grape/dsl/middleware.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module Middleware @@ -18,28 +16,28 @@ module ClassMethods # to inject. def use(middleware_class, *args, &block) arr = [:use, middleware_class, *args] - arr << block if block_given? + arr << block if block namespace_stackable(:middleware, arr) end def insert(*args, &block) arr = [:insert, *args] - arr << block if block_given? + arr << block if block namespace_stackable(:middleware, arr) end def insert_before(*args, &block) arr = [:insert_before, *args] - arr << block if block_given? + arr << block if block namespace_stackable(:middleware, arr) end def insert_after(*args, &block) arr = [:insert_after, *args] - arr << block if block_given? + arr << block if block namespace_stackable(:middleware, arr) end diff --git a/lib/grape/dsl/parameters.rb b/lib/grape/dsl/parameters.rb index 9d393fd93..6002aff66 100644 --- a/lib/grape/dsl/parameters.rb +++ b/lib/grape/dsl/parameters.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL # Defines DSL methods, meant to be applied to a ParamsScope, which define @@ -25,14 +23,14 @@ module Parameters # class API < Grape::API # desc "Get collection" # params do - # build_with Grape::Extensions::Hashie::Mash::ParamBuilder + # build_with :hashie_mash # requires :user_id, type: Integer # end # get do # params['user_id'] # end # end - def build_with(build_with = nil) + def build_with(build_with) @api.namespace_inheritable(:build_params_with, build_with) end @@ -64,7 +62,12 @@ def use(*names) params_block = named_params.fetch(name) do raise "Params :#{name} not found!" end - instance_exec(options, ¶ms_block) + + if options.empty? + instance_exec(options, ¶ms_block) + else + instance_exec(**options, ¶ms_block) + end end end alias use_scope use @@ -72,7 +75,7 @@ def use(*names) # Require one or more parameters for the current endpoint. # - # @param attrs list of parameter names, or, if :using is + # @param attrs list of parameters names, or, if :using is # passed as an option, which keys to include (:all or :none) from # the :using hash. The last key can be a hash, which specifies # options for the parameters @@ -127,13 +130,13 @@ def requires(*attrs, &block) opts = attrs.extract_options!.clone opts[:presence] = { value: true, message: opts[:message] } - opts = @group.merge(opts) if instance_variable_defined?(:@group) && @group + opts = @group.deep_merge(opts) if instance_variable_defined?(:@group) && @group if opts[:using] require_required_and_optional_fields(attrs.first, opts) else validate_attributes(attrs, opts, &block) - block_given? ? new_scope(orig_attrs, &block) : push_declared_params(attrs, **opts.slice(:as)) + block ? new_scope(orig_attrs, &block) : push_declared_params(attrs, opts.slice(:as)) end end @@ -146,12 +149,12 @@ def optional(*attrs, &block) opts = attrs.extract_options!.clone type = opts[:type] - opts = @group.merge(opts) if instance_variable_defined?(:@group) && @group + opts = @group.deep_merge(opts) if instance_variable_defined?(:@group) && @group # check type for optional parameter group - if attrs && block_given? - raise Grape::Exceptions::MissingGroupTypeError.new if type.nil? - raise Grape::Exceptions::UnsupportedGroupTypeError.new unless Grape::Validations::Types.group?(type) + if attrs && block + raise Grape::Exceptions::MissingGroupType if type.nil? + raise Grape::Exceptions::UnsupportedGroupType unless Grape::Validations::Types.group?(type) end if opts[:using] @@ -159,7 +162,7 @@ def optional(*attrs, &block) else validate_attributes(attrs, opts, &block) - block_given? ? new_scope(orig_attrs, true, &block) : push_declared_params(attrs, **opts.slice(:as)) + block ? new_scope(orig_attrs, true, &block) : push_declared_params(attrs, opts.slice(:as)) end end @@ -167,7 +170,8 @@ def optional(*attrs, &block) # @param (see #requires) # @option (see #requires) def with(*attrs, &block) - new_group_scope(attrs.clone, &block) + new_group_attrs = [@group, attrs.clone.first].compact.reduce(&:deep_merge) + new_group_scope([new_group_attrs], &block) end # Disallow the given parameters to be present in the same request. @@ -219,21 +223,25 @@ def declared_param?(param) else # @declared_params also includes hashes of options and such, but those # won't be flattened out. - @declared_params.flatten.any? do |declared_param| - first_hash_key_or_param(declared_param) == param + @declared_params.flatten.any? do |declared_param_attr| + first_hash_key_or_param(declared_param_attr.key) == param end end end alias group requires - def map_params(params, element) + class EmptyOptionalValue; end # rubocop:disable Lint/EmptyClass + + def map_params(params, element, is_array = false) if params.is_a?(Array) params.map do |el| - map_params(el, element) + map_params(el, element, true) end elsif params.is_a?(Hash) - params[element] || {} + params[element] || (@optional && is_array ? EmptyOptionalValue : {}) + elsif params == EmptyOptionalValue + EmptyOptionalValue else {} end @@ -243,7 +251,7 @@ def map_params(params, element) # @return hash of parameters relevant for the current scope # @api private def params(params) - params = @parent.params(params) if instance_variable_defined?(:@parent) && @parent + params = @parent.params_meeting_dependency.presence || @parent.params(params) if instance_variable_defined?(:@parent) && @parent params = map_params(params, @element) if instance_variable_defined?(:@element) && @element params end diff --git a/lib/grape/dsl/request_response.rb b/lib/grape/dsl/request_response.rb index cbb1db912..7f0a0d27d 100644 --- a/lib/grape/dsl/request_response.rb +++ b/lib/grape/dsl/request_response.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module RequestResponse @@ -19,17 +17,16 @@ def default_format(new_format = nil) # Specify the format for the API's serializers. # May be `:json`, `:xml`, `:txt`, etc. def format(new_format = nil) - if new_format - namespace_inheritable(:format, new_format.to_sym) - # define the default error formatters - namespace_inheritable(:default_error_formatter, Grape::ErrorFormatter.formatter_for(new_format, **{})) - # define a single mime type - mime_type = content_types[new_format.to_sym] - raise Grape::Exceptions::MissingMimeType.new(new_format) unless mime_type - namespace_stackable(:content_types, new_format.to_sym => mime_type) - else - namespace_inheritable(:format) - end + return namespace_inheritable(:format) unless new_format + + symbolic_new_format = new_format.to_sym + namespace_inheritable(:format, symbolic_new_format) + namespace_inheritable(:default_error_formatter, Grape::ErrorFormatter.formatter_for(symbolic_new_format)) + + content_type = content_types[symbolic_new_format] + raise Grape::Exceptions::MissingMimeType.new(new_format) unless content_type + + namespace_stackable(:content_types, symbolic_new_format => content_type) end # Specify a custom formatter for a content-type. @@ -44,12 +41,10 @@ def parser(content_type, new_parser) # Specify a default error formatter. def default_error_formatter(new_formatter_name = nil) - if new_formatter_name - new_formatter = Grape::ErrorFormatter.formatter_for(new_formatter_name, **{}) - namespace_inheritable(:default_error_formatter, new_formatter) - else - namespace_inheritable(:default_error_formatter) - end + return namespace_inheritable(:default_error_formatter) unless new_formatter_name + + new_formatter = Grape::ErrorFormatter.formatter_for(new_formatter_name) + namespace_inheritable(:default_error_formatter, new_formatter) end def error_formatter(format, options) @@ -102,22 +97,22 @@ def default_error_status(new_status = nil) def rescue_from(*args, &block) if args.last.is_a?(Proc) handler = args.pop - elsif block_given? + elsif block handler = block end options = args.extract_options! - if block_given? && options.key?(:with) - raise ArgumentError, 'both :with option and block cannot be passed' - end + raise ArgumentError, 'both :with option and block cannot be passed' if block && options.key?(:with) + handler ||= extract_with(options) if args.include?(:all) namespace_inheritable(:rescue_all, true) - namespace_inheritable :all_rescue_handler, handler + namespace_inheritable(:all_rescue_handler, handler) elsif args.include?(:grape_exceptions) namespace_inheritable(:rescue_all, true) namespace_inheritable(:rescue_grape_exceptions, true) + namespace_inheritable(:grape_exceptions_rescue_handler, handler) else handler_type = case options[:rescue_subclasses] @@ -127,7 +122,7 @@ def rescue_from(*args, &block) :base_only_rescue_handlers end - namespace_reverse_stackable handler_type, Hash[args.map { |arg| [arg, handler] }] + namespace_reverse_stackable(handler_type, args.to_h { |arg| [arg, handler] }) end namespace_stackable(:rescue_options, options) @@ -154,7 +149,8 @@ def rescue_from(*args, &block) # @param model_class [Class] The model class that will be represented. # @option options [Class] :with The entity class that will represent the model. def represent(model_class, options) - raise Grape::Exceptions::InvalidWithOptionForRepresent.new unless options[:with] && options[:with].is_a?(Class) + raise Grape::Exceptions::InvalidWithOptionForRepresent.new unless options[:with].is_a?(Class) + namespace_stackable(:representations, model_class => options[:with]) end @@ -162,9 +158,11 @@ def represent(model_class, options) def extract_with(options) return unless options.key?(:with) + with_option = options.delete(:with) return with_option if with_option.instance_of?(Proc) return with_option.to_sym if with_option.instance_of?(Symbol) || with_option.instance_of?(String) + raise ArgumentError, "with: #{with_option.class}, expected Symbol, String or Proc" end end diff --git a/lib/grape/dsl/routing.rb b/lib/grape/dsl/routing.rb index 76a22a79f..8c32db364 100644 --- a/lib/grape/dsl/routing.rb +++ b/lib/grape/dsl/routing.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module Routing @@ -32,13 +30,13 @@ def version(*args, &block) if args.any? options = args.extract_options! options = options.reverse_merge(using: :path) - requested_versions = args.flatten + requested_versions = args.flatten.map(&:to_s) raise Grape::Exceptions::MissingVendorOption.new if options[:using] == :header && !options.key?(:vendor) @versions = versions | requested_versions - if block_given? + if block within_namespace do namespace_inheritable(:version, requested_versions) namespace_inheritable(:version_options, options) @@ -56,7 +54,7 @@ def version(*args, &block) # Define a root URL prefix for your entire API. def prefix(prefix = nil) - namespace_inheritable(:root_prefix, prefix) + namespace_inheritable(:root_prefix, prefix&.to_s) end # Create a scope without affecting the URL. @@ -69,6 +67,10 @@ def scope(_name = nil, &block) end end + def build_with(build_with) + namespace_inheritable(:build_params_with, build_with) + end + # Do not route HEAD requests to GET requests automatically. def do_not_route_head! namespace_inheritable(:do_not_route_head, true) @@ -79,11 +81,16 @@ def do_not_route_options! namespace_inheritable(:do_not_route_options, true) end - def mount(mounts, **opts) + def do_not_document! + namespace_inheritable(:do_not_document, true) + end + + def mount(mounts, *opts) mounts = { mounts => '/' } unless mounts.respond_to?(:each_pair) mounts.each_pair do |app, path| if app.respond_to?(:mount_instance) - mount(app.mount_instance(configuration: opts[:with] || {}) => path) + opts_with = opts.any? ? opts.first[:with] : {} + mount({ app.mount_instance(configuration: opts_with) => path }, *opts) next end in_setting = inheritable_setting @@ -100,6 +107,15 @@ def mount(mounts, **opts) change! end + # When trying to mount multiple times the same endpoint, remove the previous ones + # from the list of endpoints if refresh_already_mounted parameter is true + refresh_already_mounted = opts.any? ? opts.first[:refresh_already_mounted] : false + if refresh_already_mounted && !endpoints.empty? + endpoints.delete_if do |endpoint| + endpoint.options[:app].to_s == app.to_s + end + end + endpoints << Grape::Endpoint.new( in_setting, method: :any, @@ -142,7 +158,7 @@ def route(methods, paths = ['/'], route_options = {}, &block) reset_validations! end - Grape::Http::Headers::SUPPORTED_METHODS.each do |supported_method| + Grape::HTTP_SUPPORTED_METHODS.each do |supported_method| define_method supported_method.downcase do |*args, &block| options = args.extract_options! paths = args.first || ['/'] @@ -151,7 +167,7 @@ def route(methods, paths = ['/'], route_options = {}, &block) end # Declare a "namespace", which prefixes all subordinate routes with its - # name. Any endpoints within a namespace, or group, resource, segment, + # name. Any endpoints within a namespace, group, resource or segment, # etc., will share their parent context as well as any configuration # done in the namespace context. # @@ -163,19 +179,12 @@ def route(methods, paths = ['/'], route_options = {}, &block) # end # end def namespace(space = nil, options = {}, &block) - @namespace_description = nil unless instance_variable_defined?(:@namespace_description) && @namespace_description - - if space || block_given? - within_namespace do - previous_namespace_description = @namespace_description - @namespace_description = (@namespace_description || {}).deep_merge(namespace_setting(:description) || {}) - nest(block) do - namespace_stackable(:namespace, Namespace.new(space, **options)) if space - end - @namespace_description = previous_namespace_description + return Namespace.joined_space_path(namespace_stackable(:namespace)) unless space || block + + within_namespace do + nest(block) do + namespace_stackable(:namespace, Namespace.new(space, options)) if space end - else - Namespace.joined_space_path(namespace_stackable(:namespace)) end end @@ -199,7 +208,7 @@ def reset_endpoints! @endpoints = [] end - # Thie method allows you to quickly define a parameter route segment + # This method allows you to quickly define a parameter route segment # in your API. # # @param param [Symbol] The name of the parameter you wish to declare. @@ -222,6 +231,13 @@ def route_param(param, options = {}, &block) def versions @versions ||= [] end + + private + + def refresh_mounted_api(mounts, *opts) + opts << { refresh_already_mounted: true } + mount(mounts, *opts) + end end end end diff --git a/lib/grape/dsl/settings.rb b/lib/grape/dsl/settings.rb index 706f8adb6..5c145a467 100644 --- a/lib/grape/dsl/settings.rb +++ b/lib/grape/dsl/settings.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL # Keeps track of settings (implemented as key-value pairs, grouped by @@ -103,19 +101,17 @@ def namespace_reverse_stackable(key, value = nil) def namespace_stackable_with_hash(key) settings = get_or_set :namespace_stackable, key, nil return if settings.blank? + settings.each_with_object({}) { |value, result| result.deep_merge!(value) } end def namespace_reverse_stackable_with_hash(key) settings = get_or_set :namespace_reverse_stackable, key, nil return if settings.blank? - result = {} - settings.each do |setting| - setting.each do |field, value| - result[field] ||= value - end + + settings.each_with_object({}) do |setting, result| + result.merge!(setting) { |_k, s1, _s2| s1 } end - result end # (see #unset_global_setting) @@ -154,10 +150,10 @@ def route_end # Execute the block within a context where our inheritable settings are forked # to a new copy (see #namespace_start). - def within_namespace(&_block) + def within_namespace(&block) namespace_start - result = yield if block_given? + result = yield if block namespace_end reset_validations! @@ -175,9 +171,7 @@ def build_top_level_setting # +inheritable_setting+, however, it doesn't contain any user-defined settings. # Otherwise, it would lead to an extra instance of +Grape::Util::InheritableSetting+ # in the chain for every endpoint. - if defined?(superclass) && superclass.respond_to?(:inheritable_setting) && superclass != Grape::API::Instance - setting.inherit_from superclass.inheritable_setting - end + setting.inherit_from superclass.inheritable_setting if defined?(superclass) && superclass.respond_to?(:inheritable_setting) && superclass != Grape::API::Instance end end end diff --git a/lib/grape/dsl/validations.rb b/lib/grape/dsl/validations.rb index d2d354fb1..81d71bfeb 100644 --- a/lib/grape/dsl/validations.rb +++ b/lib/grape/dsl/validations.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'active_support/concern' - module Grape module DSL module Validations @@ -32,7 +30,6 @@ def reset_validations! unset_namespace_stackable :declared_params unset_namespace_stackable :validations unset_namespace_stackable :params - unset_description_field :params end # Opens a root-level ParamsScope, defining parameter coercions and @@ -42,16 +39,17 @@ def params(&block) Grape::Validations::ParamsScope.new(api: self, type: Hash, &block) end - def document_attribute(names, opts) - setting = description_field(:params) - setting ||= description_field(:params, {}) - Array(names).each do |name| - full_name = name[:full_name].to_s - setting[full_name] ||= {} - setting[full_name].merge!(opts) + # 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) - namespace_stackable(:params, full_name => opts) - end + Grape::Validations::ContractScope.new(self, contract, &block) end end end diff --git a/lib/grape/eager_load.rb b/lib/grape/eager_load.rb deleted file mode 100644 index ef7bc3ec7..000000000 --- a/lib/grape/eager_load.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -Grape.eager_load! -Grape::Http.eager_load! -Grape::Exceptions.eager_load! -Grape::Extensions.eager_load! -Grape::Extensions::ActiveSupport.eager_load! -Grape::Extensions::Hashie.eager_load! -Grape::Middleware.eager_load! -Grape::Middleware::Auth.eager_load! -Grape::Middleware::Versioner.eager_load! -Grape::Util.eager_load! -Grape::ErrorFormatter.eager_load! -Grape::Formatter.eager_load! -Grape::Parser.eager_load! -Grape::DSL.eager_load! -Grape::API.eager_load! -Grape::Presenters.eager_load! -Grape::ServeStream.eager_load! -Rack::Head # AutoLoads the Rack::Head diff --git a/lib/grape/endpoint.rb b/lib/grape/endpoint.rb index 7a8439692..2e5136fda 100644 --- a/lib/grape/endpoint.rb +++ b/lib/grape/endpoint.rb @@ -6,21 +6,26 @@ module Grape # on the instance level of this class may be called # from inside a `get`, `post`, etc. class Endpoint + extend Forwardable include Grape::DSL::Settings include Grape::DSL::InsideRoute attr_accessor :block, :source, :options - attr_reader :env, :request, :headers, :params + attr_reader :env, :request + + def_delegators :request, :params, :headers, :cookies + def_delegator :cookies, :response_cookies class << self - def new(*args, &block) - self == Endpoint ? Class.new(Endpoint).new(*args, &block) : super + def new(...) + self == Endpoint ? Class.new(Endpoint).new(...) : super end def before_each(new_setup = false, &block) @before_each ||= [] if new_setup == false - return @before_each unless block_given? + return @before_each unless block + @before_each << block else @before_each = [new_setup] @@ -29,7 +34,7 @@ def before_each(new_setup = false, &block) def run_before_each(endpoint) superclass.run_before_each(endpoint) unless self == Endpoint - before_each.each { |blk| blk.call(endpoint) if blk.respond_to?(:call) } + before_each.each { |blk| blk.try(:call, endpoint) } end # @api private @@ -46,9 +51,7 @@ def run_before_each(endpoint) # @return [Proc] # @raise [NameError] an instance method with the same name already exists def generate_api_method(method_name, &block) - if method_defined?(method_name) - raise NameError.new("method #{method_name.inspect} already exists and cannot be used as an unbound method name") - end + raise NameError.new("method #{method_name.inspect} already exists and cannot be used as an unbound method name") if method_defined?(method_name) define_method(method_name, &block) method = instance_method(method_name) @@ -56,7 +59,7 @@ def generate_api_method(method_name, &block) proc do |endpoint_instance| ActiveSupport::Notifications.instrument('endpoint_render.grape', endpoint: endpoint_instance) do - method.bind(endpoint_instance).call + method.bind_call(endpoint_instance) end end end @@ -106,7 +109,7 @@ def initialize(new_settings, options = {}, &block) @body = nil @proc = nil - return unless block_given? + return unless block @source = block @block = self.class.generate_api_method(method_name, &block) @@ -115,14 +118,12 @@ def initialize(new_settings, options = {}, &block) # Update our settings from a given set of stackable parameters. Used when # the endpoint's API is mounted under another one. def inherit_settings(namespace_stackable) - inheritable_setting.route[:saved_validations] += namespace_stackable[:validations] + parent_validations = namespace_stackable[:validations] + inheritable_setting.route[:saved_validations].concat(parent_validations) if parent_validations.any? parent_declared_params = namespace_stackable[:declared_params] + inheritable_setting.route[:declared_params].concat(parent_declared_params.flatten) if parent_declared_params.any? - if parent_declared_params - inheritable_setting.route[:declared_params].concat(parent_declared_params.flatten) - end - - endpoints && endpoints.each { |e| e.inherit_settings(namespace_stackable) } + endpoints&.each { |e| e.inherit_settings(namespace_stackable) } end def require_option(options, key) @@ -138,50 +139,45 @@ def method_name end def routes - @routes ||= endpoints ? endpoints.collect(&:routes).flatten : to_routes + @routes ||= endpoints&.collect(&:routes)&.flatten || to_routes end def reset_routes! - endpoints.each(&:reset_routes!) if endpoints + endpoints&.each(&:reset_routes!) @namespace = nil @routes = nil end def mount_in(router) - if endpoints - endpoints.each { |e| e.mount_in(router) } - else - reset_routes! - routes.each do |route| - methods = [route.request_method] - if !namespace_inheritable(:do_not_route_head) && route.request_method == Grape::Http::Headers::GET - methods << Grape::Http::Headers::HEAD - end - methods.each do |method| - unless route.request_method == method - route = Grape::Router::Route.new(method, route.origin, **route.attributes.to_h) - end - router.append(route.apply(self)) - end + return endpoints.each { |e| e.mount_in(router) } if endpoints + + reset_routes! + routes.each do |route| + router.append(route.apply(self)) + next unless !namespace_inheritable(:do_not_route_head) && route.request_method == Rack::GET + + route.dup.then do |head_route| + head_route.convert_to_head_request! + router.append(head_route.apply(self)) end end end def to_routes - route_options = prepare_default_route_attributes - map_routes do |method, path| - path = prepare_path(path) - params = merge_route_options(**route_options.merge(suffix: path.suffix)) - route = Router::Route.new(method, path.path, **params) + default_route_options = prepare_default_route_attributes + + map_routes do |method, raw_path| + prepared_path = Path.new(raw_path, namespace, prepare_default_path_settings) + params = options[:route_options].present? ? options[:route_options].merge(default_route_options) : default_route_options + route = Grape::Router::Route.new(method, prepared_path.origin, prepared_path.suffix, params) route.apply(self) end.flatten end def prepare_routes_requirements - endpoint_requirements = options[:route_options][:requirements] || {} - all_requirements = (namespace_stackable(:namespace).map(&:requirements) << endpoint_requirements) - all_requirements.reduce({}) do |base_requirements, single_requirements| - base_requirements.merge!(single_requirements) + {}.merge!(*namespace_stackable(:namespace).map(&:requirements)).tap do |requirements| + endpoint_requirements = options.dig(:route_options, :requirements) + requirements.merge!(endpoint_requirements) if endpoint_requirements end end @@ -198,22 +194,20 @@ def prepare_default_route_attributes end def prepare_version - version = namespace_inheritable(:version) || [] - return if version.empty? - version.length == 1 ? version.first.to_s : version - end + version = namespace_inheritable(:version) + return if version.blank? - def merge_route_options(**default) - options[:route_options].clone.merge!(**default) + version.length == 1 ? version.first : version end def map_routes options[:method].map { |method| options[:path].map { |path| yield method, path } } end - def prepare_path(path) - path_settings = inheritable_setting.to_hash[:namespace_stackable].merge(inheritable_setting.to_hash[:namespace_inheritable]) - Path.prepare(path, namespace, path_settings) + def prepare_default_path_settings + namespace_stackable_hash = inheritable_setting.namespace_stackable.to_hash + namespace_inheritable_hash = inheritable_setting.namespace_inheritable.to_hash + namespace_stackable_hash.merge!(namespace_inheritable_hash) end def namespace @@ -234,41 +228,47 @@ def call!(env) # Return the collection of endpoints within this endpoint. # This is the case when an Grape::API mounts another Grape::API. def endpoints - options[:app].endpoints if options[:app] && options[:app].respond_to?(:endpoints) + @endpoints ||= options[:app].try(:endpoints) end - def equals?(e) - (options == e.options) && (inheritable_setting.to_hash == e.inheritable_setting.to_hash) + def equals?(endpoint) + (options == endpoint.options) && (inheritable_setting.to_hash == endpoint.inheritable_setting.to_hash) + end + + # The purpose of this override is solely for stripping internals when an error occurs while calling + # an endpoint through an api. See https://github.com/ruby-grape/grape/issues/2398 + # Otherwise, it calls super. + def inspect + return super unless env + + "#{self.class} in '#{route.origin}' endpoint" end protected def run ActiveSupport::Notifications.instrument('endpoint_run.grape', endpoint: self, env: env) do - @header = {} @request = Grape::Request.new(env, build_params_with: namespace_inheritable(:build_params_with)) - @params = @request.params - @headers = @request.headers begin - cookies.read(@request) self.class.run_before_each(self) run_filters befores, :before - if (allowed_methods = env[Grape::Env::GRAPE_ALLOWED_METHODS]) - raise Grape::Exceptions::MethodNotAllowed, header.merge('Allow' => allowed_methods) unless options? - header 'Allow', allowed_methods + if env.key?(Grape::Env::GRAPE_ALLOWED_METHODS) + header['Allow'] = env[Grape::Env::GRAPE_ALLOWED_METHODS].join(', ') + raise Grape::Exceptions::MethodNotAllowed.new(header) unless options? + + header 'Allow', header['Allow'] response_object = '' status 204 else run_filters before_validations, :before_validation run_validators validations, request - remove_renamed_params run_filters after_validations, :after_validation response_object = execute end run_filters afters, :after - cookies.write(header) + build_response_cookies # status verifies body presence when DELETE @body ||= response_object @@ -283,62 +283,8 @@ def run end end - def build_stack(helpers) - stack = Grape::Middleware::Stack.new - - stack.use Rack::Head - stack.use Class.new(Grape::Middleware::Error), - helpers: helpers, - format: namespace_inheritable(:format), - content_types: namespace_stackable_with_hash(:content_types), - default_status: namespace_inheritable(:default_error_status), - rescue_all: namespace_inheritable(:rescue_all), - rescue_grape_exceptions: namespace_inheritable(:rescue_grape_exceptions), - default_error_formatter: namespace_inheritable(:default_error_formatter), - error_formatters: namespace_stackable_with_hash(:error_formatters), - rescue_options: namespace_stackable_with_hash(:rescue_options) || {}, - rescue_handlers: namespace_reverse_stackable_with_hash(:rescue_handlers) || {}, - base_only_rescue_handlers: namespace_stackable_with_hash(:base_only_rescue_handlers) || {}, - all_rescue_handler: namespace_inheritable(:all_rescue_handler) - - stack.concat namespace_stackable(:middleware) - - if namespace_inheritable(:version) - stack.use Grape::Middleware::Versioner.using(namespace_inheritable(:version_options)[:using]), - versions: namespace_inheritable(:version) ? namespace_inheritable(:version).flatten : nil, - version_options: namespace_inheritable(:version_options), - prefix: namespace_inheritable(:root_prefix), - mount_path: namespace_stackable(:mount_path).first - end - - stack.use Grape::Middleware::Formatter, - format: namespace_inheritable(:format), - default_format: namespace_inheritable(:default_format) || :txt, - content_types: namespace_stackable_with_hash(:content_types), - formatters: namespace_stackable_with_hash(:formatters), - parsers: namespace_stackable_with_hash(:parsers) - - builder = stack.build - builder.run ->(env) { env[Grape::Env::API_ENDPOINT].run } - builder.to_app - end - - def build_helpers - helpers = namespace_stackable(:helpers) || [] - Module.new { helpers.each { |mod_to_include| include mod_to_include } } - end - - def remove_renamed_params - return unless route_setting(:renamed_params) - route_setting(:renamed_params).flat_map(&:keys).each do |renamed_param| - @params.delete(renamed_param) - end - end - - private :build_stack, :build_helpers, :remove_renamed_params - def execute - @block ? @block.call(self) : nil + @block&.call(self) end def helpers @@ -351,29 +297,25 @@ def lazy_initialize! @lazy_initialize_lock.synchronize do return true if @lazy_initialized - @helpers = build_helpers.tap { |mod| self.class.send(:include, mod) } + @helpers = build_helpers&.tap { |mod| self.class.include mod } @app = options[:app] || build_stack(@helpers) @lazy_initialized = true end end - def run_validators(validator_factories, request) + def run_validators(validators, request) validation_errors = [] - validators = validator_factories.map { |options| Grape::Validations::ValidatorFactory.create_validator(**options) } - ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators: validators, request: request) do validators.each do |validator| - begin - validator.validate(request) - rescue Grape::Exceptions::Validation => e - validation_errors << e - break if validator.fail_fast? - rescue Grape::Exceptions::ValidationArrayErrors => e - validation_errors.concat e.errors - break if validator.fail_fast? - end + validator.validate(request) + rescue Grape::Exceptions::Validation => e + validation_errors << e + break if validator.fail_fast? + rescue Grape::Exceptions::ValidationArrayErrors => e + validation_errors.concat e.errors + break if validator.fail_fast? end end @@ -382,39 +324,89 @@ def run_validators(validator_factories, request) def run_filters(filters, type = :other) ActiveSupport::Notifications.instrument('endpoint_run_filters.grape', endpoint: self, filters: filters, type: type) do - (filters || []).each { |filter| instance_eval(&filter) } + filters&.each { |filter| instance_eval(&filter) } end post_extension = DSL::InsideRoute.post_filter_methods(type) extend post_extension if post_extension end - def befores - namespace_stackable(:befores) || [] + %i[befores before_validations after_validations afters finallies].each do |method| + define_method method do + namespace_stackable(method) + end end - def before_validations - namespace_stackable(:before_validations) || [] - end + def validations + return enum_for(:validations) unless block_given? - def after_validations - namespace_stackable(:after_validations) || [] + route_setting(:saved_validations)&.each do |saved_validation| + yield Grape::Validations::ValidatorFactory.create_validator(saved_validation) + end end - def afters - namespace_stackable(:afters) || [] + def options? + options[:options_route_enabled] && + env[Rack::REQUEST_METHOD] == Rack::OPTIONS end - def finallies - namespace_stackable(:finallies) || [] + private + + def build_stack(helpers) + stack = Grape::Middleware::Stack.new + + content_types = namespace_stackable_with_hash(:content_types) + format = namespace_inheritable(:format) + + stack.use Rack::Head + stack.use Class.new(Grape::Middleware::Error), + helpers: helpers, + format: format, + content_types: content_types, + default_status: namespace_inheritable(:default_error_status), + rescue_all: namespace_inheritable(:rescue_all), + rescue_grape_exceptions: namespace_inheritable(:rescue_grape_exceptions), + default_error_formatter: namespace_inheritable(:default_error_formatter), + error_formatters: namespace_stackable_with_hash(:error_formatters), + rescue_options: namespace_stackable_with_hash(:rescue_options), + rescue_handlers: namespace_reverse_stackable_with_hash(:rescue_handlers), + base_only_rescue_handlers: namespace_stackable_with_hash(:base_only_rescue_handlers), + all_rescue_handler: namespace_inheritable(:all_rescue_handler), + grape_exceptions_rescue_handler: namespace_inheritable(:grape_exceptions_rescue_handler) + + stack.concat namespace_stackable(:middleware) + + if namespace_inheritable(:version).present? + stack.use Grape::Middleware::Versioner.using(namespace_inheritable(:version_options)[:using]), + versions: namespace_inheritable(:version).flatten, + version_options: namespace_inheritable(:version_options), + prefix: namespace_inheritable(:root_prefix), + mount_path: namespace_stackable(:mount_path).first + end + + stack.use Grape::Middleware::Formatter, + format: format, + default_format: namespace_inheritable(:default_format) || :txt, + content_types: content_types, + formatters: namespace_stackable_with_hash(:formatters), + parsers: namespace_stackable_with_hash(:parsers) + + builder = stack.build + builder.run ->(env) { env[Grape::Env::API_ENDPOINT].run } + builder.to_app end - def validations - route_setting(:saved_validations) || [] + def build_helpers + helpers = namespace_stackable(:helpers) + return if helpers.empty? + + Module.new { helpers.each { |mod_to_include| include mod_to_include } } end - def options? - options[:options_route_enabled] && - env[Grape::Http::Headers::REQUEST_METHOD] == Grape::Http::Headers::OPTIONS + def build_response_cookies + response_cookies do |name, value| + cookie_value = value.is_a?(Hash) ? value : { value: value } + Rack::Utils.set_cookie_header! header, name, cookie_value + end end end end diff --git a/lib/grape/util/env.rb b/lib/grape/env.rb similarity index 74% rename from lib/grape/util/env.rb rename to lib/grape/env.rb index a6023bcc1..09392fd2d 100644 --- a/lib/grape/util/env.rb +++ b/lib/grape/env.rb @@ -11,11 +11,6 @@ module Env API_VENDOR = 'api.vendor' API_FORMAT = 'api.format' - RACK_INPUT = 'rack.input' - RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' - RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' - RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' - GRAPE_REQUEST = 'grape.request' GRAPE_REQUEST_HEADERS = 'grape.request.headers' GRAPE_REQUEST_PARAMS = 'grape.request.params' diff --git a/lib/grape/error_formatter.rb b/lib/grape/error_formatter.rb index 4d76fe296..7d9ace8a8 100644 --- a/lib/grape/error_formatter.rb +++ b/lib/grape/error_formatter.rb @@ -2,34 +2,14 @@ module Grape module ErrorFormatter - extend Util::Registrable + extend Grape::Util::Registry - class << self - def builtin_formatters - @builtin_formatters ||= { - serializable_hash: Grape::ErrorFormatter::Json, - json: Grape::ErrorFormatter::Json, - jsonapi: Grape::ErrorFormatter::Json, - txt: Grape::ErrorFormatter::Txt, - xml: Grape::ErrorFormatter::Xml - } - end + module_function - def formatters(**options) - builtin_formatters.merge(default_elements).merge!(options[:error_formatters] || {}) - end + def formatter_for(format, error_formatters = nil, default_error_formatter = nil) + return error_formatters[format] if error_formatters&.key?(format) - def formatter_for(api_format, **options) - spec = formatters(**options)[api_format] - case spec - when nil - options[:default_error_formatter] || Grape::ErrorFormatter::Txt - when Symbol - method(spec) - else - spec - end - end + registry[format] || default_error_formatter || Grape::ErrorFormatter::Txt end end end diff --git a/lib/grape/error_formatter/base.rb b/lib/grape/error_formatter/base.rb index f0c802a45..f2cf223c6 100644 --- a/lib/grape/error_formatter/base.rb +++ b/lib/grape/error_formatter/base.rb @@ -2,36 +2,66 @@ module Grape module ErrorFormatter - module Base - def present(message, env) - present_options = {} - presented_message = message - if presented_message.is_a?(Hash) - presented_message = presented_message.dup - present_options[:with] = presented_message.delete(:with) + class Base + class << self + def call(message, backtrace, options = {}, env = nil, original_exception = nil) + merge_backtrace = backtrace.present? && options.dig(:rescue_options, :backtrace) + merge_original_exception = original_exception && options.dig(:rescue_options, :original_exception) + + wrapped_message = wrap_message(present(message, env)) + if wrapped_message.is_a?(Hash) + wrapped_message[:backtrace] = backtrace if merge_backtrace + wrapped_message[:original_exception] = original_exception.inspect if merge_original_exception + end + + format_structured_message(wrapped_message) end - presenter = env[Grape::Env::API_ENDPOINT].entity_class_for_obj(presented_message, present_options) + def present(message, env) + present_options = {} + presented_message = message + if presented_message.is_a?(Hash) + presented_message = presented_message.dup + present_options[:with] = presented_message.delete(:with) + end + + presenter = env[Grape::Env::API_ENDPOINT].entity_class_for_obj(presented_message, present_options) + + unless presenter || env[Grape::Env::GRAPE_ROUTING_ARGS].nil? + # env['api.endpoint'].route does not work when the error occurs within a middleware + # the Endpoint does not have a valid env at this moment + http_codes = env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info].http_codes || [] + + found_code = http_codes.find do |http_code| + (http_code[0].to_i == env[Grape::Env::API_ENDPOINT].status) && http_code[2].respond_to?(:represent) + end if env[Grape::Env::API_ENDPOINT].request - unless presenter || env[Grape::Env::GRAPE_ROUTING_ARGS].nil? - # env['api.endpoint'].route does not work when the error occurs within a middleware - # the Endpoint does not have a valid env at this moment - http_codes = env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info].http_codes || [] + presenter = found_code[2] if found_code + end - found_code = http_codes.find do |http_code| - (http_code[0].to_i == env[Grape::Env::API_ENDPOINT].status) && http_code[2].respond_to?(:represent) - end if env[Grape::Env::API_ENDPOINT].request + if presenter + embeds = { env: env } + embeds[:version] = env[Grape::Env::API_VERSION] if env.key?(Grape::Env::API_VERSION) + presented_message = presenter.represent(presented_message, embeds).serializable_hash + end - presenter = found_code[2] if found_code + presented_message end - if presenter - embeds = { env: env } - embeds[:version] = env[Grape::Env::API_VERSION] if env[Grape::Env::API_VERSION] - presented_message = presenter.represent(presented_message, embeds).serializable_hash + def wrap_message(message) + return message if message.is_a?(Hash) + + { message: message } + end + + def format_structured_message(_structured_message) + raise NotImplementedError end - presented_message + def inherited(klass) + super + ErrorFormatter.register(klass) + end end end end diff --git a/lib/grape/error_formatter/json.rb b/lib/grape/error_formatter/json.rb index 6c160e099..bed5bd39d 100644 --- a/lib/grape/error_formatter/json.rb +++ b/lib/grape/error_formatter/json.rb @@ -2,31 +2,25 @@ module Grape module ErrorFormatter - module Json - extend Base - + class Json < Base class << self - def call(message, backtrace, options = {}, env = nil, original_exception = nil) - result = wrap_message(present(message, env)) - - rescue_options = options[:rescue_options] || {} - if rescue_options[:backtrace] && backtrace && !backtrace.empty? - result = result.merge(backtrace: backtrace) - end - if rescue_options[:original_exception] && original_exception - result = result.merge(original_exception: original_exception.inspect) - end - ::Grape::Json.dump(result) + def format_structured_message(structured_message) + ::Grape::Json.dump(structured_message) end private def wrap_message(message) - if message.is_a?(Exceptions::ValidationErrors) || message.is_a?(Hash) - message - else - { error: message } - end + return message if message.is_a?(Hash) + return message.as_json if message.is_a?(Exceptions::ValidationErrors) + + { error: ensure_utf8(message) } + end + + def ensure_utf8(message) + return message unless message.respond_to? :encode + + message.encode('UTF-8', invalid: :replace, undef: :replace) end end end diff --git a/lib/grape/error_formatter/serializable_hash.rb b/lib/grape/error_formatter/serializable_hash.rb new file mode 100644 index 000000000..14b9ed597 --- /dev/null +++ b/lib/grape/error_formatter/serializable_hash.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Grape + module ErrorFormatter + class SerializableHash < Json; end + end +end diff --git a/lib/grape/error_formatter/txt.rb b/lib/grape/error_formatter/txt.rb index 76d3cb3f1..7c0c75918 100644 --- a/lib/grape/error_formatter/txt.rb +++ b/lib/grape/error_formatter/txt.rb @@ -2,25 +2,19 @@ module Grape module ErrorFormatter - module Txt - extend Base - - class << self - def call(message, backtrace, options = {}, env = nil, original_exception = nil) - message = present(message, env) - - result = message.is_a?(Hash) ? ::Grape::Json.dump(message) : message - rescue_options = options[:rescue_options] || {} - if rescue_options[:backtrace] && backtrace && !backtrace.empty? - result += "\r\n backtrace:" - result += backtrace.join("\r\n ") + class Txt < Base + def self.format_structured_message(structured_message) + message = structured_message[:message] || Grape::Json.dump(structured_message) + Array.wrap(message).tap do |final_message| + if structured_message.key?(:backtrace) + final_message << 'backtrace:' + final_message.concat(structured_message[:backtrace]) end - if rescue_options[:original_exception] && original_exception - result += "\r\n original exception:" - result += "\r\n #{original_exception.inspect}" + if structured_message.key?(:original_exception) + final_message << 'original exception:' + final_message << structured_message[:original_exception] end - result - end + end.join("\r\n ") end end end diff --git a/lib/grape/error_formatter/xml.rb b/lib/grape/error_formatter/xml.rb index 39ea87387..c78f6d650 100644 --- a/lib/grape/error_formatter/xml.rb +++ b/lib/grape/error_formatter/xml.rb @@ -2,23 +2,9 @@ module Grape module ErrorFormatter - module Xml - extend Base - - class << self - def call(message, backtrace, options = {}, env = nil, original_exception = nil) - message = present(message, env) - - result = message.is_a?(Hash) ? message : { message: message } - rescue_options = options[:rescue_options] || {} - if rescue_options[:backtrace] && backtrace && !backtrace.empty? - result = result.merge(backtrace: backtrace) - end - if rescue_options[:original_exception] && original_exception - result = result.merge(original_exception: original_exception.inspect) - end - result.respond_to?(:to_xml) ? result.to_xml(root: :error) : result.to_s - end + class Xml < Base + def self.format_structured_message(structured_message) + structured_message.respond_to?(:to_xml) ? structured_message.to_xml(root: :error) : structured_message.to_s end end end diff --git a/lib/grape/exceptions/base.rb b/lib/grape/exceptions/base.rb index 9eeba9301..27ef78b45 100644 --- a/lib/grape/exceptions/base.rb +++ b/lib/grape/exceptions/base.rb @@ -7,11 +7,12 @@ class Base < StandardError BASE_ATTRIBUTES_KEY = 'grape.errors.attributes' FALLBACK_LOCALE = :en - attr_reader :status, :message, :headers + attr_reader :status, :headers + + def initialize(status: nil, message: nil, headers: nil) + super(message) - def initialize(status: nil, message: nil, headers: nil, **_options) @status = status - @message = message @headers = headers end @@ -25,42 +26,32 @@ def [](index) # if BASE_ATTRIBUTES_KEY.key respond to a string message, then short_message is returned # if BASE_ATTRIBUTES_KEY.key respond to a Hash, means it may have problem , summary and resolution def compose_message(key, **attributes) - short_message = translate_message(key, **attributes) - if short_message.is_a? Hash - @problem = problem(key, **attributes) - @summary = summary(key, **attributes) - @resolution = resolution(key, **attributes) - [['Problem', @problem], ['Summary', @summary], ['Resolution', @resolution]].each_with_object(+'') do |detail_array, message| - message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank? - message - end - else - short_message - end - end + short_message = translate_message(key, attributes) + return short_message unless short_message.is_a?(Hash) - def problem(key, **attributes) - translate_message("#{key}.problem".to_sym, **attributes) + each_steps(key, attributes).with_object(+'') do |detail_array, message| + message << "\n#{detail_array[0]}:\n #{detail_array[1]}" unless detail_array[1].blank? + end end - def summary(key, **attributes) - translate_message("#{key}.summary".to_sym, **attributes) - end + def each_steps(key, attributes) + return enum_for(:each_steps, key, attributes) unless block_given? - def resolution(key, **attributes) - translate_message("#{key}.resolution".to_sym, **attributes) + yield 'Problem', translate_message(:"#{key}.problem", attributes) + yield 'Summary', translate_message(:"#{key}.summary", attributes) + yield 'Resolution', translate_message(:"#{key}.resolution", attributes) end - def translate_attributes(keys, **options) + def translate_attributes(keys, options = {}) keys.map do |key| - translate("#{BASE_ATTRIBUTES_KEY}.#{key}", default: key, **options) + translate("#{BASE_ATTRIBUTES_KEY}.#{key}", options.merge(default: key.to_s)) end.join(', ') end - def translate_message(key, **options) + def translate_message(key, options = {}) case key when Symbol - translate("#{BASE_MESSAGES_KEY}.#{key}", default: '', **options) + translate("#{BASE_MESSAGES_KEY}.#{key}", options.merge(default: '')) when Proc key.call else @@ -68,15 +59,13 @@ def translate_message(key, **options) end end - def translate(key, **options) - options = options.dup - options[:default] &&= options[:default].to_s + def translate(key, options) message = ::I18n.translate(key, **options) - message.present? ? message : fallback_message(key, **options) + message.presence || fallback_message(key, options) end - def fallback_message(key, **options) - if ::I18n.enforce_available_locales && !::I18n.available_locales.include?(FALLBACK_LOCALE) + def fallback_message(key, options) + if ::I18n.enforce_available_locales && ::I18n.available_locales.exclude?(FALLBACK_LOCALE) key else ::I18n.translate(key, locale: FALLBACK_LOCALE, **options) diff --git a/lib/grape/exceptions/conflicting_types.rb b/lib/grape/exceptions/conflicting_types.rb new file mode 100644 index 000000000..9228cd903 --- /dev/null +++ b/lib/grape/exceptions/conflicting_types.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class ConflictingTypes < Base + def initialize + super(message: compose_message(:conflicting_types), status: 400) + end + end + end +end diff --git a/lib/grape/exceptions/empty_message_body.rb b/lib/grape/exceptions/empty_message_body.rb new file mode 100644 index 000000000..c4fd43176 --- /dev/null +++ b/lib/grape/exceptions/empty_message_body.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class EmptyMessageBody < Base + def initialize(body_format) + super(message: compose_message(:empty_message_body, body_format: body_format), status: 400) + end + end + end +end diff --git a/lib/grape/exceptions/invalid_parameters.rb b/lib/grape/exceptions/invalid_parameters.rb new file mode 100644 index 000000000..c060c8c08 --- /dev/null +++ b/lib/grape/exceptions/invalid_parameters.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class InvalidParameters < Base + def initialize + super(message: compose_message(:invalid_parameters), status: 400) + end + end + end +end diff --git a/lib/grape/exceptions/missing_group_type.rb b/lib/grape/exceptions/missing_group_type.rb index 398113ff8..9a7d3180a 100644 --- a/lib/grape/exceptions/missing_group_type.rb +++ b/lib/grape/exceptions/missing_group_type.rb @@ -2,10 +2,12 @@ module Grape module Exceptions - class MissingGroupTypeError < Base + class MissingGroupType < Base def initialize super(message: compose_message(:missing_group_type)) end end end end + +Grape::Exceptions::MissingGroupTypeError = ActiveSupport::Deprecation::DeprecatedConstantProxy.new('Grape::Exceptions::MissingGroupTypeError', 'Grape::Exceptions::MissingGroupType', Grape.deprecator) diff --git a/lib/grape/exceptions/too_deep_parameters.rb b/lib/grape/exceptions/too_deep_parameters.rb new file mode 100644 index 000000000..c71267705 --- /dev/null +++ b/lib/grape/exceptions/too_deep_parameters.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class TooDeepParameters < Base + def initialize(limit) + super(message: compose_message(:too_deep_parameters, limit: limit), status: 400) + end + end + end +end diff --git a/lib/grape/exceptions/too_many_multipart_files.rb b/lib/grape/exceptions/too_many_multipart_files.rb new file mode 100644 index 000000000..72deffb39 --- /dev/null +++ b/lib/grape/exceptions/too_many_multipart_files.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class TooManyMultipartFiles < Base + def initialize(limit) + super(message: compose_message(:too_many_multipart_files, limit: limit), status: 413) + end + end + end +end diff --git a/lib/grape/exceptions/unknown_params_builder.rb b/lib/grape/exceptions/unknown_params_builder.rb new file mode 100644 index 000000000..449860998 --- /dev/null +++ b/lib/grape/exceptions/unknown_params_builder.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module Exceptions + class UnknownParamsBuilder < Base + def initialize(params_builder_type) + super(message: compose_message(:unknown_params_builder, params_builder_type: params_builder_type)) + end + end + end +end diff --git a/lib/grape/exceptions/unsupported_group_type.rb b/lib/grape/exceptions/unsupported_group_type.rb index 9cbc7aac2..4c5e6396a 100644 --- a/lib/grape/exceptions/unsupported_group_type.rb +++ b/lib/grape/exceptions/unsupported_group_type.rb @@ -2,10 +2,12 @@ module Grape module Exceptions - class UnsupportedGroupTypeError < Base + class UnsupportedGroupType < Base def initialize super(message: compose_message(:unsupported_group_type)) end end end end + +Grape::Exceptions::UnsupportedGroupTypeError = ActiveSupport::Deprecation::DeprecatedConstantProxy.new('Grape::Exceptions::UnsupportedGroupTypeError', 'Grape::Exceptions::UnsupportedGroupType', Grape.deprecator) diff --git a/lib/grape/exceptions/validation.rb b/lib/grape/exceptions/validation.rb index bfd1dee2d..0a9e9c5dd 100644 --- a/lib/grape/exceptions/validation.rb +++ b/lib/grape/exceptions/validation.rb @@ -1,31 +1,25 @@ # frozen_string_literal: true -require 'grape/exceptions/base' - module Grape module Exceptions - class Validation < Grape::Exceptions::Base - attr_accessor :params - attr_accessor :message_key + class Validation < Base + attr_accessor :params, :message_key - def initialize(params:, message: nil, **args) + def initialize(params:, message: nil, status: nil, headers: nil) @params = params if message @message_key = message if message.is_a?(Symbol) - args[:message] = translate_message(message) + message = translate_message(message) end - super(**args) + + super(status: status, message: message, headers: headers) end - # remove all the unnecessary stuff from Grape::Exceptions::Base like status + # Remove all the unnecessary stuff from Grape::Exceptions::Base like status # and headers when converting a validation error to json or string def as_json(*_args) to_s end - - def to_s - message - end end end end diff --git a/lib/grape/exceptions/validation_array_errors.rb b/lib/grape/exceptions/validation_array_errors.rb index d596c8877..d7815b1f6 100644 --- a/lib/grape/exceptions/validation_array_errors.rb +++ b/lib/grape/exceptions/validation_array_errors.rb @@ -6,6 +6,7 @@ class ValidationArrayErrors < Base attr_reader :errors def initialize(errors) + super() @errors = errors end end diff --git a/lib/grape/exceptions/validation_errors.rb b/lib/grape/exceptions/validation_errors.rb index f8bf32e31..8859d579b 100644 --- a/lib/grape/exceptions/validation_errors.rb +++ b/lib/grape/exceptions/validation_errors.rb @@ -1,25 +1,18 @@ # frozen_string_literal: true -require 'grape/exceptions/base' - module Grape module Exceptions - class ValidationErrors < Grape::Exceptions::Base + class ValidationErrors < Base ERRORS_FORMAT_KEY = 'grape.errors.format' - DEFAULT_ERRORS_FORMAT = '%{attributes} %{message}' + DEFAULT_ERRORS_FORMAT = '%s %s' include Enumerable attr_reader :errors - def initialize(errors: [], headers: {}, **_options) - @errors = {} - errors.each do |validation_error| - @errors[validation_error.params] ||= [] - @errors[validation_error.params] << validation_error - end - - super message: full_messages.join(', '), status: 400, headers: headers + def initialize(errors: [], headers: {}) + @errors = errors.group_by(&:params) + super(message: full_messages.join(', '), status: 400, headers: headers) end def each @@ -39,7 +32,7 @@ def as_json(**_opts) end end - def to_json(**_opts) + def to_json(*_opts) as_json.to_json end diff --git a/lib/grape/extensions/active_support/hash_with_indifferent_access.rb b/lib/grape/extensions/active_support/hash_with_indifferent_access.rb index d412c91dd..c90b734b7 100644 --- a/lib/grape/extensions/active_support/hash_with_indifferent_access.rb +++ b/lib/grape/extensions/active_support/hash_with_indifferent_access.rb @@ -8,17 +8,14 @@ module ParamBuilder extend ::ActiveSupport::Concern included do - namespace_inheritable(:build_params_with, Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder) - end - - def params_builder - Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder + Grape.deprecator.warn 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' + namespace_inheritable(:build_params_with, :hash_with_indifferent_access) end def build_params - params = ::ActiveSupport::HashWithIndifferentAccess.new(rack_params) - params.deep_merge!(grape_routing_args) if env[Grape::Env::GRAPE_ROUTING_ARGS] - params + ::ActiveSupport::HashWithIndifferentAccess.new(rack_params).tap do |params| + params.deep_merge!(grape_routing_args) if env.key?(Grape::Env::GRAPE_ROUTING_ARGS) + end end end end diff --git a/lib/grape/extensions/deep_mergeable_hash.rb b/lib/grape/extensions/deep_mergeable_hash.rb deleted file mode 100644 index 825bbe892..000000000 --- a/lib/grape/extensions/deep_mergeable_hash.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Extensions - class DeepMergeableHash < ::Hash - def deep_merge!(other_hash) - other_hash.each_pair do |current_key, other_value| - this_value = self[current_key] - - self[current_key] = if this_value.is_a?(::Hash) && other_value.is_a?(::Hash) - this_value.deep_merge(other_value) - else - other_value - end - end - - self - end - end - end -end diff --git a/lib/grape/extensions/deep_symbolize_hash.rb b/lib/grape/extensions/deep_symbolize_hash.rb deleted file mode 100644 index 6c131a97b..000000000 --- a/lib/grape/extensions/deep_symbolize_hash.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Extensions - module DeepSymbolizeHash - def self.deep_symbolize_keys_in(object) - case object - when ::Hash - object.each_with_object({}) do |(key, value), new_hash| - new_hash[symbolize_key(key)] = deep_symbolize_keys_in(value) - end - when ::Array - object.map { |element| deep_symbolize_keys_in(element) } - else - object - end - end - - def self.symbolize_key(key) - if key.is_a?(Symbol) - key - elsif key.is_a?(String) - key.to_sym - elsif key.respond_to?(:to_sym) - key.to_sym - else - key - end - end - end - end -end diff --git a/lib/grape/extensions/hash.rb b/lib/grape/extensions/hash.rb index 990dddfbc..6d5ef6e65 100644 --- a/lib/grape/extensions/hash.rb +++ b/lib/grape/extensions/hash.rb @@ -7,17 +7,19 @@ module ParamBuilder extend ::ActiveSupport::Concern included do - namespace_inheritable(:build_params_with, Grape::Extensions::Hash::ParamBuilder) + Grape.deprecator.warn 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' + namespace_inheritable(:build_params_with, :hash) end def build_params - params = Grape::Extensions::DeepMergeableHash[rack_params] - params.deep_merge!(grape_routing_args) if env[Grape::Env::GRAPE_ROUTING_ARGS] - post_process_params(params) - end + rack_params.deep_dup.tap do |params| + params.deep_symbolize_keys! - def post_process_params(params) - Grape::Extensions::DeepSymbolizeHash.deep_symbolize_keys_in(params) + if env.key?(Grape::Env::GRAPE_ROUTING_ARGS) + grape_routing_args.deep_symbolize_keys! + params.deep_merge!(grape_routing_args) + end + end end end end diff --git a/lib/grape/extensions/hashie/mash.rb b/lib/grape/extensions/hashie/mash.rb index 545e71952..c144b4fd2 100644 --- a/lib/grape/extensions/hashie/mash.rb +++ b/lib/grape/extensions/hashie/mash.rb @@ -6,18 +6,16 @@ module Hashie module Mash module ParamBuilder extend ::ActiveSupport::Concern - included do - namespace_inheritable(:build_params_with, Grape::Extensions::Hashie::Mash::ParamBuilder) - end - def params_builder - Grape::Extensions::Hashie::Mash::ParamBuilder + included do + Grape.deprecator.warn 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' + namespace_inheritable(:build_params_with, :hashie_mash) end def build_params - params = ::Hashie::Mash.new(rack_params) - params.deep_merge!(grape_routing_args) if env[Grape::Env::GRAPE_ROUTING_ARGS] - params + ::Hashie::Mash.new(rack_params).tap do |params| + params.deep_merge!(grape_routing_args) if env.key?(Grape::Env::GRAPE_ROUTING_ARGS) + end end end end diff --git a/lib/grape/formatter.rb b/lib/grape/formatter.rb index 4a84f0e2b..6d5affb34 100644 --- a/lib/grape/formatter.rb +++ b/lib/grape/formatter.rb @@ -2,34 +2,16 @@ module Grape module Formatter - extend Util::Registrable + extend Grape::Util::Registry - class << self - def builtin_formatters - @builtin_formatters ||= { - json: Grape::Formatter::Json, - jsonapi: Grape::Formatter::Json, - serializable_hash: Grape::Formatter::SerializableHash, - txt: Grape::Formatter::Txt, - xml: Grape::Formatter::Xml - } - end + module_function - def formatters(**options) - builtin_formatters.merge(default_elements).merge!(options[:formatters] || {}) - end + DEFAULT_LAMBDA_FORMATTER = ->(obj, _env) { obj } - def formatter_for(api_format, **options) - spec = formatters(**options)[api_format] - case spec - when nil - ->(obj, _env) { obj } - when Symbol - method(spec) - else - spec - end - end + def formatter_for(api_format, formatters) + return formatters[api_format] if formatters&.key?(api_format) + + registry[api_format] || DEFAULT_LAMBDA_FORMATTER end end end diff --git a/lib/grape/formatter/base.rb b/lib/grape/formatter/base.rb new file mode 100644 index 000000000..dfd56d65d --- /dev/null +++ b/lib/grape/formatter/base.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Grape + module Formatter + class Base + def self.call(_object, _env) + raise NotImplementedError + end + + def self.inherited(klass) + super + Formatter.register(klass) + end + end + end +end diff --git a/lib/grape/formatter/json.rb b/lib/grape/formatter/json.rb index 753468a3c..bfdd2ca28 100644 --- a/lib/grape/formatter/json.rb +++ b/lib/grape/formatter/json.rb @@ -2,12 +2,11 @@ module Grape module Formatter - module Json - class << self - def call(object, _env) - return object.to_json if object.respond_to?(:to_json) - ::Grape::Json.dump(object) - end + class Json < Base + def self.call(object, _env) + return object.to_json if object.respond_to?(:to_json) + + ::Grape::Json.dump(object) end end end diff --git a/lib/grape/formatter/serializable_hash.rb b/lib/grape/formatter/serializable_hash.rb index 5b6256d59..5b29ece15 100644 --- a/lib/grape/formatter/serializable_hash.rb +++ b/lib/grape/formatter/serializable_hash.rb @@ -2,36 +2,37 @@ module Grape module Formatter - module SerializableHash + class SerializableHash < Base class << self def call(object, _env) return object if object.is_a?(String) return ::Grape::Json.dump(serialize(object)) if serializable?(object) return object.to_json if object.respond_to?(:to_json) + ::Grape::Json.dump(object) end private def serializable?(object) - object.respond_to?(:serializable_hash) || object.is_a?(Array) && object.all? { |o| o.respond_to? :serializable_hash } || object.is_a?(Hash) + object.respond_to?(:serializable_hash) || array_serializable?(object) || object.is_a?(Hash) end def serialize(object) if object.respond_to? :serializable_hash object.serializable_hash - elsif object.is_a?(Array) && object.all? { |o| o.respond_to? :serializable_hash } + elsif array_serializable?(object) object.map(&:serializable_hash) elsif object.is_a?(Hash) - h = {} - object.each_pair do |k, v| - h[k] = serialize(v) - end - h + object.transform_values { |v| serialize(v) } else object end end + + def array_serializable?(object) + object.is_a?(Array) && object.all? { |o| o.respond_to? :serializable_hash } + end end end end diff --git a/lib/grape/formatter/txt.rb b/lib/grape/formatter/txt.rb index 1f1f9ef2f..cb77e4f07 100644 --- a/lib/grape/formatter/txt.rb +++ b/lib/grape/formatter/txt.rb @@ -2,11 +2,9 @@ module Grape module Formatter - module Txt - class << self - def call(object, _env) - object.respond_to?(:to_txt) ? object.to_txt : object.to_s - end + class Txt < Base + def self.call(object, _env) + object.respond_to?(:to_txt) ? object.to_txt : object.to_s end end end diff --git a/lib/grape/formatter/xml.rb b/lib/grape/formatter/xml.rb index 9db91d015..de0531758 100644 --- a/lib/grape/formatter/xml.rb +++ b/lib/grape/formatter/xml.rb @@ -2,12 +2,11 @@ module Grape module Formatter - module Xml - class << self - def call(object, _env) - return object.to_xml if object.respond_to?(:to_xml) - raise Grape::Exceptions::InvalidFormatter.new(object.class, 'xml') - end + class Xml < Base + def self.call(object, _env) + return object.to_xml if object.respond_to?(:to_xml) + + raise Grape::Exceptions::InvalidFormatter.new(object.class, 'xml') end end end diff --git a/lib/grape/http/headers.rb b/lib/grape/http/headers.rb deleted file mode 100644 index 564d97ff8..000000000 --- a/lib/grape/http/headers.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -require 'grape/util/lazy_object' - -module Grape - module Http - module Headers - # https://github.com/rack/rack/blob/master/lib/rack.rb - HTTP_VERSION = 'HTTP_VERSION' - PATH_INFO = 'PATH_INFO' - REQUEST_METHOD = 'REQUEST_METHOD' - QUERY_STRING = 'QUERY_STRING' - CONTENT_TYPE = 'Content-Type' - - GET = 'GET' - POST = 'POST' - PUT = 'PUT' - PATCH = 'PATCH' - DELETE = 'DELETE' - HEAD = 'HEAD' - OPTIONS = 'OPTIONS' - - SUPPORTED_METHODS = [GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS].freeze - SUPPORTED_METHODS_WITHOUT_OPTIONS = Grape::Util::LazyObject.new { [GET, POST, PUT, PATCH, DELETE, HEAD].freeze } - - HTTP_ACCEPT_VERSION = 'HTTP_ACCEPT_VERSION' - X_CASCADE = 'X-Cascade' - HTTP_TRANSFER_ENCODING = 'HTTP_TRANSFER_ENCODING' - HTTP_ACCEPT = 'HTTP_ACCEPT' - - FORMAT = 'format' - - HTTP_HEADERS = Grape::Util::LazyObject.new do - common_http_headers = %w[ - Version - Host - Connection - Cache-Control - Dnt - Upgrade-Insecure-Requests - User-Agent - Sec-Fetch-Dest - Accept - Sec-Fetch-Site - Sec-Fetch-Mode - Sec-Fetch-User - Accept-Encoding - Accept-Language - Cookie - ].freeze - common_http_headers.each_with_object({}) do |header, response| - response["HTTP_#{header.upcase.tr('-', '_')}"] = header - end.freeze - end - - def self.find_supported_method(route_method) - Grape::Http::Headers::SUPPORTED_METHODS.detect { |supported_method| supported_method.casecmp(route_method).zero? } - end - end - end -end diff --git a/lib/grape/util/json.rb b/lib/grape/json.rb similarity index 79% rename from lib/grape/util/json.rb rename to lib/grape/json.rb index 9381d841a..a30014538 100644 --- a/lib/grape/util/json.rb +++ b/lib/grape/json.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Grape - if Object.const_defined? :MultiJson + if defined?(::MultiJson) Json = ::MultiJson else Json = ::JSON diff --git a/lib/grape/locale/en.yml b/lib/grape/locale/en.yml index 10fa381f7..212e98999 100644 --- a/lib/grape/locale/en.yml +++ b/lib/grape/locale/en.yml @@ -10,9 +10,13 @@ en: values: 'does not have a valid value' except_values: 'has a value not allowed' same_as: 'is not the same as %{parameter}' + length: 'is expected to have length within %{min} and %{max}' + length_is: 'is expected to have length exactly equal to %{is}' + length_min: 'is expected to have length greater than or equal to %{min}' + length_max: 'is expected to have length less than or equal to %{max}' missing_vendor_option: - problem: 'missing :vendor option.' - summary: 'when version using header, you must specify :vendor option. ' + problem: 'missing :vendor option' + summary: 'when version using header, you must specify :vendor option' resolution: "eg: version 'v1', using: :header, vendor: 'twitter'" missing_mime_type: problem: 'missing mime type for %{new_format}' @@ -21,14 +25,15 @@ en: or add your own with content_type :%{new_format}, 'application/%{new_format}' " invalid_with_option_for_represent: - problem: 'You must specify an entity class in the :with option.' + problem: 'you must specify an entity class in the :with option' resolution: 'eg: represent User, :with => Entity::User' - missing_option: 'You must specify :%{option} options.' + missing_option: 'you must specify :%{option} options' invalid_formatter: 'cannot convert %{klass} to %{to_format}' invalid_versioner_option: - problem: 'Unknown :using for versioner: %{strategy}' + problem: 'unknown :using for versioner: %{strategy}' resolution: 'available strategy for :using is :path, :header, :accept_version_header, :param' unknown_validator: 'unknown validator: %{validator_type}' + unknown_params_builder: 'unknown params_builder: %{params_builder_type}' unknown_options: 'unknown options: %{options}' unknown_parameter: 'unknown parameter: %{param}' incompatible_option_values: '%{option1}: %{value1} is incompatible with %{option2}: %{value2}' @@ -44,11 +49,15 @@ en: "when specifying %{body_format} as content-type, you must pass valid %{body_format} in the request's 'body' " + empty_message_body: 'empty message body supplied with %{body_format} content-type' + too_many_multipart_files: "the number of uploaded files exceeded the system's configured limit (%{limit})" invalid_accept_header: - problem: 'Invalid accept header' + problem: 'invalid accept header' resolution: '%{message}' invalid_version_header: - problem: 'Invalid version header' + problem: 'invalid version header' resolution: '%{message}' invalid_response: 'Invalid response' - + conflicting_types: 'query params contains conflicting types' + invalid_parameters: 'query params contains invalid format or byte sequence' + too_deep_parameters: 'query params are recursively nested over the specified limit (%{limit})' diff --git a/lib/grape/middleware/auth/base.rb b/lib/grape/middleware/auth/base.rb index 61a8cc588..d7703cd78 100644 --- a/lib/grape/middleware/auth/base.rb +++ b/lib/grape/middleware/auth/base.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rack/auth/basic' - module Grape module Middleware module Auth @@ -10,9 +8,9 @@ class Base attr_accessor :options, :app, :env - def initialize(app, **options) + def initialize(app, *options) @app = app - @options = options + @options = options.shift end def call(env) @@ -23,12 +21,12 @@ def _call(env) self.env = env if options.key?(:type) - auth_proc = options[:proc] + auth_proc = options[:proc] auth_proc_context = context strategy_info = Grape::Middleware::Auth::Strategies[options[:type]] - throw(:error, status: 401, message: 'API Authorization Failed.') unless strategy_info.present? + throw(:error, status: 401, message: 'API Authorization Failed.') if strategy_info.blank? strategy = strategy_info.create(@app, options) do |*args| auth_proc_context.instance_exec(*args, &auth_proc) diff --git a/lib/grape/middleware/auth/dsl.rb b/lib/grape/middleware/auth/dsl.rb index 1b2e8f456..598358d9d 100644 --- a/lib/grape/middleware/auth/dsl.rb +++ b/lib/grape/middleware/auth/dsl.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'rack/auth/basic' -require 'active_support/concern' - module Grape module Middleware module Auth @@ -32,7 +29,13 @@ def http_basic(options = {}, &block) def http_digest(options = {}, &block) options[:realm] ||= 'API Authorization' - options[:opaque] ||= 'secret' + + if options[:realm].respond_to?(:values_at) + options[:realm][:opaque] ||= 'secret' + else + options[:opaque] ||= 'secret' + end + auth :http_digest, options, &block end end diff --git a/lib/grape/middleware/auth/strategies.rb b/lib/grape/middleware/auth/strategies.rb index dc36eea48..56855263e 100644 --- a/lib/grape/middleware/auth/strategies.rb +++ b/lib/grape/middleware/auth/strategies.rb @@ -12,8 +12,7 @@ def add(label, strategy, option_fetcher = ->(_) { [] }) def auth_strategies @auth_strategies ||= { - http_basic: StrategyInfo.new(Rack::Auth::Basic, ->(settings) { [settings[:realm]] }), - http_digest: StrategyInfo.new(Rack::Auth::Digest::MD5, ->(settings) { [settings[:realm], settings[:opaque]] }) + http_basic: StrategyInfo.new(Rack::Auth::Basic, ->(settings) { [settings[:realm]] }) } end diff --git a/lib/grape/middleware/base.rb b/lib/grape/middleware/base.rb index e21a94e9e..d7a50d598 100644 --- a/lib/grape/middleware/base.rb +++ b/lib/grape/middleware/base.rb @@ -1,22 +1,18 @@ # frozen_string_literal: true -require 'grape/dsl/headers' - module Grape module Middleware class Base include Helpers + include Grape::DSL::Headers attr_reader :app, :env, :options - TEXT_HTML = 'text/html' - - include Grape::DSL::Headers # @param [Rack Application] app The standard argument for a Rack middleware. # @param [Hash] options A hash of options, simply stored for use by subclasses. - def initialize(app, **options) + def initialize(app, *options) @app = app - @options = default_options.merge(options) + @options = options.any? ? default_options.deep_merge(options.shift) : default_options @app_response = nil end @@ -56,38 +52,54 @@ def before; end # @return [Response, nil] a Rack SPEC response or nil to call the application afterwards. def after; end + def rack_request + @rack_request ||= Rack::Request.new(env) + end + def response return @app_response if @app_response.is_a?(Rack::Response) - Rack::Response.new(@app_response[2], @app_response[0], @app_response[1]) - end - def content_type_for(format) - HashWithIndifferentAccess.new(content_types)[format] + @app_response = Rack::Response.new(@app_response[2], @app_response[0], @app_response[1]) end def content_types - ContentTypes.content_types_for(options[:content_types]) + @content_types ||= Grape::ContentTypes.content_types_for(options[:content_types]) + end + + def mime_types + @mime_types ||= Grape::ContentTypes.mime_types_for(content_types) + end + + def content_type_for(format) + content_types_indifferent_access[format] end def content_type - content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || TEXT_HTML + content_type_for(env[Grape::Env::API_FORMAT] || options[:format]) || 'text/html' end - def mime_types - @mime_type ||= content_types.each_pair.with_object({}) do |(k, v), types_without_params| - types_without_params[v.split(';').first] = k - end + def query_params + rack_request.GET + rescue Rack::QueryParser::ParamsTooDeepError + raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit) + rescue Rack::Utils::ParameterTypeError + raise Grape::Exceptions::ConflictingTypes end private def merge_headers(response) return unless headers.is_a?(Hash) + case response when Rack::Response then response.headers.merge!(headers) when Array then response[1].merge!(headers) end end + + def content_types_indifferent_access + @content_types_indifferent_access ||= content_types.with_indifferent_access + end end end end diff --git a/lib/grape/middleware/error.rb b/lib/grape/middleware/error.rb index 54bded3c8..56a6d5443 100644 --- a/lib/grape/middleware/error.rb +++ b/lib/grape/middleware/error.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' -require 'active_support/core_ext/string/output_safety' - module Grape module Middleware class Error < Base @@ -27,113 +24,125 @@ def default_options } end - def initialize(app, **options) + def initialize(app, *options) super - self.class.send(:include, @options[:helpers]) if @options[:helpers] + self.class.include(@options[:helpers]) if @options[:helpers] end def call!(env) @env = env - begin - error_response(catch(:error) do - return @app.call(@env) - end) - rescue Exception => e # rubocop:disable Lint/RescueException - handler = - rescue_handler_for_base_only_class(e.class) || - rescue_handler_for_class_or_its_ancestor(e.class) || - rescue_handler_for_grape_exception(e.class) || - rescue_handler_for_any_class(e.class) || - raise - - run_rescue_handler(handler, e) - end + error_response(catch(:error) { return @app.call(@env) }) + rescue Exception => e # rubocop:disable Lint/RescueException + run_rescue_handler(find_handler(e.class), e, @env[Grape::Env::API_ENDPOINT]) end - def error!(message, status = options[:default_status], headers = {}, backtrace = [], original_exception = nil) - headers = headers.reverse_merge(Grape::Http::Headers::CONTENT_TYPE => content_type) - rack_response(format_message(message, backtrace, original_exception), status, headers) + private + + def rack_response(status, headers, message) + message = Rack::Utils.escape_html(message) if headers[Rack::CONTENT_TYPE] == 'text/html' + Rack::Response.new(Array.wrap(message), Rack::Utils.status_code(status), Grape::Util::Header.new.merge(headers)) + end + + def format_message(message, backtrace, original_exception = nil) + format = env[Grape::Env::API_FORMAT] || options[:format] + formatter = Grape::ErrorFormatter.formatter_for(format, options[:error_formatters], options[:default_error_formatter]) + return formatter.call(message, backtrace, options, env, original_exception) if formatter + + throw :error, + status: 406, + message: "The requested format '#{format}' is not supported.", + backtrace: backtrace, + original_exception: original_exception end - def default_rescue_handler(e) - error_response(message: e.message, backtrace: e.backtrace, original_exception: e) + def find_handler(klass) + rescue_handler_for_base_only_class(klass) || + rescue_handler_for_class_or_its_ancestor(klass) || + rescue_handler_for_grape_exception(klass) || + rescue_handler_for_any_class(klass) || + raise end - # TODO: This method is deprecated. Refactor out. def error_response(error = {}) status = error[:status] || options[:default_status] + env[Grape::Env::API_ENDPOINT].status(status) # error! may not have been called message = error[:message] || options[:default_message] - headers = { Grape::Http::Headers::CONTENT_TYPE => content_type } - headers.merge!(error[:headers]) if error[:headers].is_a?(Hash) + headers = { Rack::CONTENT_TYPE => content_type }.tap do |h| + h.merge!(error[:headers]) if error[:headers].is_a?(Hash) + end backtrace = error[:backtrace] || error[:original_exception]&.backtrace || [] original_exception = error.is_a?(Exception) ? error : error[:original_exception] || nil - rack_response(format_message(message, backtrace, original_exception), status, headers) - end - - def rack_response(message, status = options[:default_status], headers = { Grape::Http::Headers::CONTENT_TYPE => content_type }) - message = ERB::Util.html_escape(message) if headers[Grape::Http::Headers::CONTENT_TYPE] == TEXT_HTML - Rack::Response.new([message], status, headers) + rack_response(status, headers, format_message(message, backtrace, original_exception)) end - def format_message(message, backtrace, original_exception = nil) - format = env[Grape::Env::API_FORMAT] || options[:format] - formatter = Grape::ErrorFormatter.formatter_for(format, **options) - throw :error, - status: 406, - message: "The requested format '#{format}' is not supported.", - backtrace: backtrace, - original_exception: original_exception unless formatter - formatter.call(message, backtrace, options, env, original_exception) + def default_rescue_handler(exception) + error_response(message: exception.message, backtrace: exception.backtrace, original_exception: exception) end - private - def rescue_handler_for_base_only_class(klass) - error, handler = options[:base_only_rescue_handlers].find { |err, _handler| klass == err } + error, handler = options[:base_only_rescue_handlers]&.find { |err, _handler| klass == err } return unless error - handler || :default_rescue_handler + handler || method(:default_rescue_handler) end def rescue_handler_for_class_or_its_ancestor(klass) - error, handler = options[:rescue_handlers].find { |err, _handler| klass <= err } + error, handler = options[:rescue_handlers]&.find { |err, _handler| klass <= err } return unless error - handler || :default_rescue_handler + handler || method(:default_rescue_handler) end def rescue_handler_for_grape_exception(klass) return unless klass <= Grape::Exceptions::Base - return :error_response if klass == Grape::Exceptions::InvalidVersionHeader + return method(:error_response) if klass == Grape::Exceptions::InvalidVersionHeader return unless options[:rescue_grape_exceptions] || !options[:rescue_all] - :error_response + options[:grape_exceptions_rescue_handler] || method(:error_response) end def rescue_handler_for_any_class(klass) return unless klass <= StandardError return unless options[:rescue_all] || options[:rescue_grape_exceptions] - options[:all_rescue_handler] || :default_rescue_handler + options[:all_rescue_handler] || method(:default_rescue_handler) end - def run_rescue_handler(handler, error) + def run_rescue_handler(handler, error, endpoint) if handler.instance_of?(Symbol) - raise NoMethodError, "undefined method `#{handler}'" unless respond_to?(handler) + raise NoMethodError, "undefined method '#{handler}'" unless respond_to?(handler) handler = public_method(handler) end - response = handler.arity.zero? ? instance_exec(&handler) : instance_exec(error, &handler) + response = catch(:error) do + handler.arity.zero? ? endpoint.instance_exec(&handler) : endpoint.instance_exec(error, &handler) + end - if response.is_a?(Rack::Response) + if error?(response) + error_response(response) + elsif response.is_a?(Rack::Response) response else - run_rescue_handler(:default_rescue_handler, Grape::Exceptions::InvalidResponse.new) + run_rescue_handler(method(:default_rescue_handler), Grape::Exceptions::InvalidResponse.new, endpoint) end end + + def error!(message, status = options[:default_status], headers = {}, backtrace = [], original_exception = nil) + env[Grape::Env::API_ENDPOINT].status(status) # not error! inside route + rack_response( + status, headers.reverse_merge(Rack::CONTENT_TYPE => content_type), + format_message(message, backtrace, original_exception) + ) + end + + def error?(response) + return false unless response.is_a?(Hash) + + response.key?(:message) && response.key?(:status) && response.key?(:headers) + end end end end diff --git a/lib/grape/middleware/formatter.rb b/lib/grape/middleware/formatter.rb index bb9888c2a..359792a40 100644 --- a/lib/grape/middleware/formatter.rb +++ b/lib/grape/middleware/formatter.rb @@ -1,12 +1,8 @@ # frozen_string_literal: true -require 'grape/middleware/base' - module Grape module Middleware class Formatter < Base - CHUNKED = 'chunked' - def default_options { default_format: :txt, @@ -22,10 +18,11 @@ def before def after return unless @app_response + status, headers, bodies = *@app_response if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status) - @app_response + [status, headers, []] else build_formatted_response(status, headers, bodies) end @@ -53,8 +50,8 @@ def build_formatted_response(status, headers, bodies) end def fetch_formatter(headers, options) - api_format = mime_types[headers[Grape::Http::Headers::CONTENT_TYPE]] || env[Grape::Env::API_FORMAT] - Grape::Formatter.formatter_for(api_format, **options) + api_format = env.fetch(Grape::Env::API_FORMAT) { mime_types[headers[Rack::CONTENT_TYPE]] } + Grape::Formatter.formatter_for(api_format, options[:formatters]) end # Set the content type header for the API format if it is not already present. @@ -62,54 +59,45 @@ def fetch_formatter(headers, options) # @param headers [Hash] # @return [Hash] def ensure_content_type(headers) - if headers[Grape::Http::Headers::CONTENT_TYPE] + if headers[Rack::CONTENT_TYPE] headers else - headers.merge(Grape::Http::Headers::CONTENT_TYPE => content_type_for(env[Grape::Env::API_FORMAT])) + headers.merge(Rack::CONTENT_TYPE => content_type_for(env[Grape::Env::API_FORMAT])) end end - def request - @request ||= Rack::Request.new(env) - end - - # store read input in env['api.request.input'] def read_body_input - return unless - (request.post? || request.put? || request.patch? || request.delete?) && - (!request.form_data? || !request.media_type) && - !request.parseable_data? && - (request.content_length.to_i > 0 || request.env[Grape::Http::Headers::HTTP_TRANSFER_ENCODING] == CHUNKED) + input = rack_request.body # reads RACK_INPUT + return if input.nil? + return unless read_body_input? - return unless (input = env[Grape::Env::RACK_INPUT]) - - input.rewind + input.try(:rewind) body = env[Grape::Env::API_REQUEST_INPUT] = input.read begin - read_rack_input(body) if body && !body.empty? + read_rack_input(body) ensure - input.rewind + input.try(:rewind) end end - # store parsed input in env['api.request.body'] def read_rack_input(body) - fmt = request.media_type ? mime_types[request.media_type] : options[:default_format] + return if body.empty? - unless content_type_for(fmt) - throw :error, status: 415, message: "The provided content-type '#{request.media_type}' is not supported." - end - parser = Grape::Parser.parser_for fmt, **options + media_type = rack_request.media_type + fmt = media_type ? mime_types[media_type] : options[:default_format] + + throw :error, status: 415, message: "The provided content-type '#{media_type}' is not supported." unless content_type_for(fmt) + parser = Grape::Parser.parser_for fmt, options[:parsers] if parser begin body = (env[Grape::Env::API_REQUEST_BODY] = parser.call(body, env)) if body.is_a?(Hash) - env[Grape::Env::RACK_REQUEST_FORM_HASH] = if env[Grape::Env::RACK_REQUEST_FORM_HASH] - env[Grape::Env::RACK_REQUEST_FORM_HASH].merge(body) - else - body - end - env[Grape::Env::RACK_REQUEST_FORM_INPUT] = env[Grape::Env::RACK_INPUT] + env[Rack::RACK_REQUEST_FORM_HASH] = if env.key?(Rack::RACK_REQUEST_FORM_HASH) + env[Rack::RACK_REQUEST_FORM_HASH].merge(body) + else + body + end + env[Rack::RACK_REQUEST_FORM_INPUT] = env[Rack::RACK_INPUT] end rescue Grape::Exceptions::Base => e raise e @@ -121,58 +109,43 @@ def read_rack_input(body) end end + # this middleware will not try to format the following content-types since Rack already handles them + # when calling Rack's `params` function + # - application/x-www-form-urlencoded + # - multipart/form-data + # - multipart/related + # - multipart/mixed + def read_body_input? + (rack_request.post? || rack_request.put? || rack_request.patch? || rack_request.delete?) && + !(rack_request.form_data? && rack_request.content_type) && + !rack_request.parseable_data? && + (rack_request.content_length.to_i.positive? || rack_request.env['HTTP_TRANSFER_ENCODING'] == 'chunked') + end + def negotiate_content_type - fmt = format_from_extension || format_from_params || options[:format] || format_from_header || options[:default_format] + fmt = format_from_extension || query_params['format'] || options[:format] || format_from_header || options[:default_format] if content_type_for(fmt) - env[Grape::Env::API_FORMAT] = fmt + env[Grape::Env::API_FORMAT] = fmt.to_sym else throw :error, status: 406, message: "The requested format '#{fmt}' is not supported." end end def format_from_extension - parts = request.path.split('.') + request_path = rack_request.path.try(:scrub) + dot_pos = request_path.rindex('.') + return unless dot_pos - if parts.size > 1 - extension = parts.last - # avoid symbol memory leak on an unknown format - return extension.to_sym if content_type_for(extension) - end - nil - end - - def format_from_params - fmt = Rack::Utils.parse_nested_query(env[Grape::Http::Headers::QUERY_STRING])[Grape::Http::Headers::FORMAT] - # avoid symbol memory leak on an unknown format - return fmt.to_sym if content_type_for(fmt) - fmt + extension = request_path[dot_pos + 1..] + extension if content_type_for(extension) end def format_from_header - mime_array.each do |t| - return mime_types[t] if mime_types.key?(t) - end - nil - end - - def mime_array - accept = env[Grape::Http::Headers::HTTP_ACCEPT] - return [] unless accept - - accept_into_mime_and_quality = %r{ - ( - \w+/[\w+.-]+) # eg application/vnd.example.myformat+xml - (?: - (?:;[^,]*?)? # optionally multiple formats in a row - ;\s*q=([\d.]+) # optional "quality" preference (eg q=0.5) - )? - }x - - vendor_prefix_pattern = /vnd\.[^+]+\+/ + accept_header = env['HTTP_ACCEPT'].try(:scrub) + return if accept_header.blank? - accept.scan(accept_into_mime_and_quality) - .sort_by { |_, quality_preference| -quality_preference.to_f } - .flat_map { |mime, _| [mime, mime.sub(vendor_prefix_pattern, '')] } + media_type = Rack::Utils.best_q_match(accept_header, mime_types.keys) + mime_types[media_type] if media_type end end end diff --git a/lib/grape/middleware/globals.rb b/lib/grape/middleware/globals.rb index 850f24196..81fa9b1f6 100644 --- a/lib/grape/middleware/globals.rb +++ b/lib/grape/middleware/globals.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' - module Grape module Middleware class Globals < Base @@ -9,7 +7,7 @@ def before request = Grape::Request.new(@env, build_params_with: @options[:build_params_with]) @env[Grape::Env::GRAPE_REQUEST] = request @env[Grape::Env::GRAPE_REQUEST_HEADERS] = request.headers - @env[Grape::Env::GRAPE_REQUEST_PARAMS] = request.params if @env[Grape::Env::RACK_INPUT] + @env[Grape::Env::GRAPE_REQUEST_PARAMS] = request.params if @env[Rack::RACK_INPUT] end end end diff --git a/lib/grape/middleware/stack.rb b/lib/grape/middleware/stack.rb index b725e8787..1ba65080e 100644 --- a/lib/grape/middleware/stack.rb +++ b/lib/grape/middleware/stack.rb @@ -5,13 +5,13 @@ module Middleware # Class to handle the stack of middlewares based on ActionDispatch::MiddlewareStack # It allows to insert and insert after class Stack + extend Forwardable class Middleware - attr_reader :args, :opts, :block, :klass + attr_reader :args, :block, :klass - def initialize(klass, *args, **opts, &block) + def initialize(klass, args, block) @klass = klass - @args = args - @opts = opts + @args = args @block = block end @@ -32,16 +32,12 @@ def inspect klass.to_s end - if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('2.7') - def use_in(builder) - block ? builder.use(klass, *args, **opts, &block) : builder.use(klass, *args, **opts) - end - else - def use_in(builder) - args = self.args - args += [opts] unless opts.empty? - block ? builder.use(klass, *args, &block) : builder.use(klass, *args) - end + def build(builder) + # we need to force the ruby2_keywords_hash for middlewares that initialize contains keywords + # like ActionDispatch::RequestId since middleware arguments are serialized + # https://rubyapi.org/3.4/o/hash#method-c-ruby2_keywords_hash + args[-1] = Hash.ruby2_keywords_hash(args[-1]) if args.last.is_a?(Hash) && Hash.respond_to?(:ruby2_keywords_hash) + builder.use(klass, *args, &block) end end @@ -49,31 +45,16 @@ def use_in(builder) attr_accessor :middlewares, :others + def_delegators :middlewares, :each, :size, :last, :[] + def initialize @middlewares = [] @others = [] end - def each - @middlewares.each { |x| yield x } - end - - def size - middlewares.size - end - - def last - middlewares.last - end - - def [](i) - middlewares[i] - end - - def insert(index, *args, &block) + def insert(index, klass, *args, &block) index = assert_index(index, :before) - middleware = self.class::Middleware.new(*args, &block) - middlewares.insert(index, middleware) + middlewares.insert(index, self.class::Middleware.new(klass, args, block)) end alias insert_before insert @@ -83,36 +64,38 @@ def insert_after(index, *args, &block) insert(index + 1, *args, &block) end - def use(*args, &block) - middleware = self.class::Middleware.new(*args, &block) + def use(klass, *args, &block) + middleware = self.class::Middleware.new(klass, args, block) middlewares.push(middleware) end def merge_with(middleware_specs) - middleware_specs.each do |operation, *args| + middleware_specs.each do |operation, klass, *args| if args.last.is_a?(Proc) last_proc = args.pop - public_send(operation, *args, &last_proc) + public_send(operation, klass, *args, &last_proc) else - public_send(operation, *args) + public_send(operation, klass, *args) end end end # @return [Rack::Builder] the builder object with our middlewares applied - def build(builder = Rack::Builder.new) - others.shift(others.size).each(&method(:merge_with)) - middlewares.each do |m| - m.use_in(builder) + def build + Rack::Builder.new.tap do |builder| + others.shift(others.size).each { |m| merge_with(m) } + middlewares.each do |m| + m.build(builder) + end end - builder end # @description Add middlewares with :use operation to the stack. Store others with :insert_* operation for later # @param [Array] other_specs An array of middleware specifications (e.g. [[:use, klass], [:insert_before, *args]]) def concat(other_specs) - @others << Array(other_specs).reject { |o| o.first == :use } - merge_with(Array(other_specs).select { |o| o.first == :use }) + use, not_use = other_specs.partition { |o| o.first == :use } + others << not_use + merge_with(use) end protected diff --git a/lib/grape/middleware/versioner.rb b/lib/grape/middleware/versioner.rb index d40c87a27..bcca9fe59 100644 --- a/lib/grape/middleware/versioner.rb +++ b/lib/grape/middleware/versioner.rb @@ -4,30 +4,23 @@ # on the requests. The current methods for determining version are: # # :header - version from HTTP Accept header. +# :accept_version_header - version from HTTP Accept-Version header # :path - version from uri. e.g. /v1/resource # :param - version from uri query string, e.g. /v1/resource?apiver=v1 -# # See individual classes for details. module Grape module Middleware module Versioner + extend Grape::Util::Registry + module_function - # @param strategy [Symbol] :path, :header or :param + # @param strategy [Symbol] :path, :header, :accept_version_header or :param # @return a middleware class based on strategy def using(strategy) - case strategy - when :path - Path - when :header - Header - when :param - Param - when :accept_version_header - AcceptVersionHeader - else - raise Grape::Exceptions::InvalidVersionerOption.new(strategy) - end + raise Grape::Exceptions::InvalidVersionerOption, strategy unless registry.key?(strategy) + + registry[strategy] end end end diff --git a/lib/grape/middleware/versioner/accept_version_header.rb b/lib/grape/middleware/versioner/accept_version_header.rb index 953a78392..1d24388f1 100644 --- a/lib/grape/middleware/versioner/accept_version_header.rb +++ b/lib/grape/middleware/versioner/accept_version_header.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' - module Grape module Middleware module Versioner @@ -20,46 +18,19 @@ module Versioner # route. class AcceptVersionHeader < Base def before - potential_version = (env[Grape::Http::Headers::HTTP_ACCEPT_VERSION] || '').strip - - if strict? - # If no Accept-Version header: - if potential_version.empty? - throw :error, status: 406, headers: error_headers, message: 'Accept-Version header must be set.' - end - end - - return if potential_version.empty? + potential_version = env['HTTP_ACCEPT_VERSION'].try(:scrub) + not_acceptable!('Accept-Version header must be set.') if strict? && potential_version.blank? - # If the requested version is not supported: - throw :error, status: 406, headers: error_headers, message: 'The requested version is not supported.' unless versions.any? { |v| v.to_s == potential_version } + return if potential_version.blank? + not_acceptable!('The requested version is not supported.') unless potential_version_match?(potential_version) env[Grape::Env::API_VERSION] = potential_version end private - def versions - options[:versions] || [] - end - - def strict? - options[:version_options] && options[:version_options][:strict] - end - - # By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking - # of routes (see Grape::Router) for more information). To prevent - # this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`. - def cascade? - if options[:version_options] && options[:version_options].key?(:cascade) - options[:version_options][:cascade] - else - true - end - end - - def error_headers - cascade? ? { Grape::Http::Headers::X_CASCADE => 'pass' } : {} + def not_acceptable!(message) + throw :error, status: 406, headers: error_headers, message: message end end end diff --git a/lib/grape/middleware/versioner/base.rb b/lib/grape/middleware/versioner/base.rb new file mode 100644 index 000000000..4cd18a8c0 --- /dev/null +++ b/lib/grape/middleware/versioner/base.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Grape + module Middleware + module Versioner + class Base < Grape::Middleware::Base + DEFAULT_PATTERN = /.*/i.freeze + DEFAULT_PARAMETER = 'apiver' + + def self.inherited(klass) + super + Versioner.register(klass) + end + + def default_options + { + versions: nil, + prefix: nil, + mount_path: nil, + pattern: DEFAULT_PATTERN, + version_options: { + strict: false, + cascade: true, + parameter: DEFAULT_PARAMETER + } + } + end + + def versions + options[:versions] + end + + def prefix + options[:prefix] + end + + def mount_path + options[:mount_path] + end + + def pattern + options[:pattern] + end + + def version_options + options[:version_options] + end + + def strict? + version_options[:strict] + end + + # By default those errors contain an `X-Cascade` header set to `pass`, which allows nesting and stacking + # of routes (see Grape::Router) for more information). To prevent + # this behavior, and not add the `X-Cascade` header, one can set the `:cascade` option to `false`. + def cascade? + version_options[:cascade] + end + + def parameter_key + version_options[:parameter] + end + + def vendor + version_options[:vendor] + end + + def error_headers + cascade? ? { 'X-Cascade' => 'pass' } : {} + end + + def potential_version_match?(potential_version) + versions.blank? || versions.any? { |v| v.to_s == potential_version } + end + + def version_not_found! + throw :error, status: 404, message: '404 API Version Not Found', headers: { 'X-Cascade' => 'pass' } + end + end + end + end +end diff --git a/lib/grape/middleware/versioner/header.rb b/lib/grape/middleware/versioner/header.rb index bb5fc1671..5470fe170 100644 --- a/lib/grape/middleware/versioner/header.rb +++ b/lib/grape/middleware/versioner/header.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' -require 'grape/middleware/versioner/parse_media_type_patch' - module Grape module Middleware module Versioner @@ -25,174 +22,104 @@ module Versioner # X-Cascade header to alert Grape::Router to attempt the next matched # route. class Header < Base - VENDOR_VERSION_HEADER_REGEX = - /\Avnd\.([a-z0-9.\-_!#\$&\^]+?)(?:-([a-z0-9*.]+))?(?:\+([a-z0-9*\-.]+))?\z/.freeze - - HAS_VENDOR_REGEX = /\Avnd\.[a-z0-9.\-_!#\$&\^]+/.freeze - HAS_VERSION_REGEX = /\Avnd\.([a-z0-9.\-_!#\$&\^]+?)(?:-([a-z0-9*.]+))+/.freeze - def before - strict_header_checks if strict? - - if media_type || env[Grape::Env::GRAPE_ALLOWED_METHODS] - media_type_header_handler - elsif headers_contain_wrong_vendor? - fail_with_invalid_accept_header!('API vendor not found.') - elsif headers_contain_wrong_version? - fail_with_invalid_version_header!('API version not found.') + match_best_quality_media_type! do |media_type| + env.update( + Grape::Env::API_TYPE => media_type.type, + Grape::Env::API_SUBTYPE => media_type.subtype, + Grape::Env::API_VENDOR => media_type.vendor, + Grape::Env::API_VERSION => media_type.version, + Grape::Env::API_FORMAT => media_type.format + ) end end private - def strict_header_checks - strict_accept_header_presence_check - strict_version_vendor_accept_header_presence_check - end - - def strict_accept_header_presence_check - return unless header.qvalues.empty? - fail_with_invalid_accept_header!('Accept header must be set.') - end - - def strict_version_vendor_accept_header_presence_check - return unless versions.present? - return if an_accept_header_with_version_and_vendor_is_present? - fail_with_invalid_accept_header!('API vendor or version not found.') - end + def match_best_quality_media_type! + return unless vendor - def an_accept_header_with_version_and_vendor_is_present? - header.qvalues.keys.any? do |h| - VENDOR_VERSION_HEADER_REGEX.match?(h.sub('application/', '')) + strict_header_checks! + media_type = Grape::Util::MediaType.best_quality(accept_header, available_media_types) + if media_type + yield media_type + else + fail! end end - def header - @header ||= rack_accept_header - end - - def media_type - @media_type ||= header.best_of(available_media_types) + def accept_header + env['HTTP_ACCEPT'] end - def media_type_header_handler - type, subtype = Rack::Accept::Header.parse_media_type(media_type) - env[Grape::Env::API_TYPE] = type - env[Grape::Env::API_SUBTYPE] = subtype - - return unless VENDOR_VERSION_HEADER_REGEX =~ subtype + def strict_header_checks! + return unless strict? - env[Grape::Env::API_VENDOR] = Regexp.last_match[1] - env[Grape::Env::API_VERSION] = Regexp.last_match[2] - # weird that Grape::Middleware::Formatter also does this - env[Grape::Env::API_FORMAT] = Regexp.last_match[3] + accept_header_check! + version_and_vendor_check! end - def fail_with_invalid_accept_header!(message) - raise Grape::Exceptions::InvalidAcceptHeader - .new(message, error_headers) - end + def accept_header_check! + return if accept_header.present? - def fail_with_invalid_version_header!(message) - raise Grape::Exceptions::InvalidVersionHeader - .new(message, error_headers) + invalid_accept_header!('Accept header must be set.') end - def available_media_types - available_media_types = [] - - content_types.each_key do |extension| - versions.reverse_each do |version| - available_media_types += [ - "application/vnd.#{vendor}-#{version}+#{extension}", - "application/vnd.#{vendor}-#{version}" - ] - end - available_media_types << "application/vnd.#{vendor}+#{extension}" - end - - available_media_types << "application/vnd.#{vendor}" - - content_types.each_value do |media_type| - available_media_types << media_type - end - - available_media_types.flatten - end + def version_and_vendor_check! + return if versions.blank? || version_and_vendor? - def headers_contain_wrong_vendor? - header.values.all? do |header_value| - vendor?(header_value) && request_vendor(header_value) != vendor - end + invalid_accept_header!('API vendor or version not found.') end - def headers_contain_wrong_version? - header.values.all? do |header_value| - version?(header_value) && !versions.include?(request_version(header_value)) - end + def q_values_mime_types + @q_values_mime_types ||= Rack::Utils.q_values(accept_header).map(&:first) end - def rack_accept_header - Rack::Accept::MediaType.new env[Grape::Http::Headers::HTTP_ACCEPT] - rescue RuntimeError => e - fail_with_invalid_accept_header!(e.message) + def version_and_vendor? + q_values_mime_types.any? { |mime_type| Grape::Util::MediaType.match?(mime_type) } end - def versions - options[:versions] || [] + def invalid_accept_header!(message) + raise Grape::Exceptions::InvalidAcceptHeader.new(message, error_headers) end - def vendor - version_options && version_options[:vendor] + def invalid_version_header!(message) + raise Grape::Exceptions::InvalidVersionHeader.new(message, error_headers) end - def strict? - version_options && version_options[:strict] - end + def fail! + return if env[Grape::Env::GRAPE_ALLOWED_METHODS].present? - def version_options - options[:version_options] + media_types = q_values_mime_types.map { |mime_type| Grape::Util::MediaType.parse(mime_type) } + vendor_not_found!(media_types) || version_not_found!(media_types) end - # By default those errors contain an `X-Cascade` header set to `pass`, - # which allows nesting and stacking of routes - # (see Grape::Router for more - # information). To prevent # this behavior, and not add the `X-Cascade` - # header, one can set the `:cascade` option to `false`. - def cascade? - if version_options && version_options.key?(:cascade) - version_options[:cascade] - else - true - end - end + def vendor_not_found!(media_types) + return unless media_types.all? { |media_type| media_type&.vendor && media_type.vendor != vendor } - def error_headers - cascade? ? { Grape::Http::Headers::X_CASCADE => 'pass' } : {} + invalid_accept_header!('API vendor not found.') end - # @param [String] media_type a content type - # @return [Boolean] whether the content type sets a vendor - def vendor?(media_type) - _, subtype = Rack::Accept::Header.parse_media_type(media_type) - subtype.present? && subtype[HAS_VENDOR_REGEX] - end + def version_not_found!(media_types) + return unless media_types.all? { |media_type| media_type&.version && versions&.exclude?(media_type.version) } - def request_vendor(media_type) - _, subtype = Rack::Accept::Header.parse_media_type(media_type) - subtype.match(VENDOR_VERSION_HEADER_REGEX)[1] + invalid_version_header!('API version not found.') end - def request_version(media_type) - _, subtype = Rack::Accept::Header.parse_media_type(media_type) - subtype.match(VENDOR_VERSION_HEADER_REGEX)[2] - end + def available_media_types + [].tap do |available_media_types| + base_media_type = "application/vnd.#{vendor}" + content_types.each_key do |extension| + versions&.reverse_each do |version| + available_media_types << "#{base_media_type}-#{version}+#{extension}" + available_media_types << "#{base_media_type}-#{version}" + end + available_media_types << "#{base_media_type}+#{extension}" + end - # @param [String] media_type a content type - # @return [Boolean] whether the content type sets an API version - def version?(media_type) - _, subtype = Rack::Accept::Header.parse_media_type(media_type) - subtype.present? && subtype[HAS_VERSION_REGEX] + available_media_types << base_media_type + available_media_types.concat(content_types.values.flatten) + end end end end diff --git a/lib/grape/middleware/versioner/param.rb b/lib/grape/middleware/versioner/param.rb index 8e7b17a4e..102d30c55 100644 --- a/lib/grape/middleware/versioner/param.rb +++ b/lib/grape/middleware/versioner/param.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' - module Grape module Middleware module Versioner @@ -21,30 +19,12 @@ module Versioner # # env['api.version'] => 'v1' class Param < Base - def default_options - { - version_options: { - parameter: 'apiver' - } - } - end - def before - potential_version = Rack::Utils.parse_nested_query(env[Grape::Http::Headers::QUERY_STRING])[paramkey] - return if potential_version.nil? - throw :error, status: 404, message: '404 API Version Not Found', headers: { Grape::Http::Headers::X_CASCADE => 'pass' } if options[:versions] && !options[:versions].find { |v| v.to_s == potential_version } - env[Grape::Env::API_VERSION] = potential_version - env[Grape::Env::RACK_REQUEST_QUERY_HASH].delete(paramkey) if env.key? Grape::Env::RACK_REQUEST_QUERY_HASH - end - - private - - def paramkey - version_options[:parameter] || default_options[:version_options][:parameter] - end + potential_version = query_params[parameter_key] + return if potential_version.blank? - def version_options - options[:version_options] + version_not_found! unless potential_version_match?(potential_version) + env[Grape::Env::API_VERSION] = env[Rack::RACK_REQUEST_QUERY_HASH].delete(parameter_key) end end end diff --git a/lib/grape/middleware/versioner/parse_media_type_patch.rb b/lib/grape/middleware/versioner/parse_media_type_patch.rb deleted file mode 100644 index 7098b32c0..000000000 --- a/lib/grape/middleware/versioner/parse_media_type_patch.rb +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -module Rack - module Accept - module Header - ALLOWED_CHARACTERS = %r{^([a-z*]+)\/([a-z0-9*\&\^\-_#\$!.+]+)(?:;([a-z0-9=;]+))?$}.freeze - class << self - # Corrected version of https://github.com/mjackson/rack-accept/blob/master/lib/rack/accept/header.rb#L40-L44 - def parse_media_type(media_type) - # see http://tools.ietf.org/html/rfc6838#section-4.2 for allowed characters in media type names - m = media_type&.match(ALLOWED_CHARACTERS) - m ? [m[1], m[2], m[3] || ''] : [] - end - end - end - - class MediaType - def parse_media_type(media_type) - Header.parse_media_type(media_type) - end - end - end -end diff --git a/lib/grape/middleware/versioner/path.rb b/lib/grape/middleware/versioner/path.rb index b7becc749..dd4379767 100644 --- a/lib/grape/middleware/versioner/path.rb +++ b/lib/grape/middleware/versioner/path.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/middleware/base' - module Grape module Middleware module Versioner @@ -19,42 +17,22 @@ module Versioner # env['api.version'] => 'v1' # class Path < Base - def default_options - { - pattern: /.*/i - } - end - def before - path = env[Grape::Http::Headers::PATH_INFO].dup - path.sub!(mount_path, '') if mounted_path?(path) + path_info = Grape::Router.normalize_path(env[Rack::PATH_INFO]) + return if path_info == '/' - if prefix && path.index(prefix) == 0 # rubocop:disable all - path.sub!(prefix, '') - path = Grape::Router.normalize_path(path) + [mount_path, Grape::Router.normalize_path(prefix)].each do |path| + path_info.delete_prefix!(path) if path.present? && path != '/' && path_info.start_with?(path) end - pieces = path.split('/') - potential_version = pieces[1] - return unless potential_version&.match?(options[:pattern]) - throw :error, status: 404, message: '404 API Version Not Found' if options[:versions] && !options[:versions].find { |v| v.to_s == potential_version } - env[Grape::Env::API_VERSION] = potential_version - end - - private + slash_position = path_info.index('/', 1) # omit the first one + return unless slash_position - def mounted_path?(path) - return false unless mount_path && path.start_with?(mount_path) - rest = path.slice(mount_path.length..-1) - rest.start_with?('/') || rest.empty? - end + potential_version = path_info[1..slash_position - 1] + return unless potential_version.match?(pattern) - def mount_path - @mount_path ||= options[:mount_path] && options[:mount_path] != '/' ? options[:mount_path] : '' - end - - def prefix - Grape::Router.normalize_path(options[:prefix].to_s) if options[:prefix] + version_not_found! unless potential_version_match?(potential_version) + env[Grape::Env::API_VERSION] = potential_version end end end diff --git a/lib/grape/namespace.rb b/lib/grape/namespace.rb index 3473d3efb..bdaa3a53f 100644 --- a/lib/grape/namespace.rb +++ b/lib/grape/namespace.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'grape/util/cache' - module Grape # A container for endpoints or other namespaces, which allows for both # logical grouping of endpoints as well as sharing common configuration. @@ -14,7 +12,7 @@ class Namespace # @option options :requirements [Hash] param-regex pairs, all of which must # be met by a request's params for all endpoints in this namespace, or # validation will fail and return a 422. - def initialize(space, **options) + def initialize(space, options) @space = space.to_s @options = options end @@ -33,13 +31,14 @@ def self.joined_space(settings) # Join the namespaces from a list of settings to create a path prefix. # @param settings [Array] list of Grape::Util::InheritableSettings. def self.joined_space_path(settings) - Grape::Router.normalize_path(JoinedSpaceCache[joined_space(settings)]) + JoinedSpaceCache[joined_space(settings)] end class JoinedSpaceCache < Grape::Util::Cache def initialize + super @cache = Hash.new do |h, joined_space| - h[joined_space] = -joined_space.join('/') + h[joined_space] = Grape::Router.normalize_path(joined_space.join('/')) end end end diff --git a/lib/grape/params_builder.rb b/lib/grape/params_builder.rb new file mode 100644 index 000000000..03cd9d1e3 --- /dev/null +++ b/lib/grape/params_builder.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Grape + module ParamsBuilder + extend Grape::Util::Registry + + SHORT_NAME_LOOKUP = { + 'Grape::Extensions::Hash::ParamBuilder' => :hash, + 'Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder' => :hash_with_indifferent_access, + 'Grape::Extensions::Hashie::Mash::ParamBuilder' => :hashie_mash + }.freeze + + module_function + + def params_builder_for(short_name) + verified_short_name = verify_short_name!(short_name) + + raise Grape::Exceptions::UnknownParamsBuilder, verified_short_name unless registry.key?(verified_short_name) + + registry[verified_short_name] + end + + def verify_short_name!(short_name) + return short_name if short_name.is_a?(Symbol) + + class_name = short_name.name + SHORT_NAME_LOOKUP[class_name].tap do |real_short_name| + Grape.deprecator.warn "#{class_name} has been deprecated. Use short name :#{real_short_name} instead." + end + end + end +end diff --git a/lib/grape/params_builder/base.rb b/lib/grape/params_builder/base.rb new file mode 100644 index 000000000..49f583d74 --- /dev/null +++ b/lib/grape/params_builder/base.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Grape + module ParamsBuilder + class Base + class << self + def call(_params) + raise NotImplementedError + end + + def inherited(klass) + super + ParamsBuilder.register(klass) + end + end + end + end +end diff --git a/lib/grape/params_builder/hash.rb b/lib/grape/params_builder/hash.rb new file mode 100644 index 000000000..25d777c7e --- /dev/null +++ b/lib/grape/params_builder/hash.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module ParamsBuilder + class Hash < Base + def self.call(params) + params.deep_symbolize_keys + end + end + end +end diff --git a/lib/grape/params_builder/hash_with_indifferent_access.rb b/lib/grape/params_builder/hash_with_indifferent_access.rb new file mode 100644 index 000000000..cce22fc21 --- /dev/null +++ b/lib/grape/params_builder/hash_with_indifferent_access.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module ParamsBuilder + class HashWithIndifferentAccess < Base + def self.call(params) + params.with_indifferent_access + end + end + end +end diff --git a/lib/grape/params_builder/hashie_mash.rb b/lib/grape/params_builder/hashie_mash.rb new file mode 100644 index 000000000..f20ff6dc1 --- /dev/null +++ b/lib/grape/params_builder/hashie_mash.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Grape + module ParamsBuilder + class HashieMash < Base + def self.call(params) + ::Hashie::Mash.new(params) + end + end + end +end diff --git a/lib/grape/parser.rb b/lib/grape/parser.rb index 3676a45a7..9dcb81ef3 100644 --- a/lib/grape/parser.rb +++ b/lib/grape/parser.rb @@ -2,32 +2,14 @@ module Grape module Parser - extend Util::Registrable + extend Grape::Util::Registry - class << self - def builtin_parsers - @builtin_parsers ||= { - json: Grape::Parser::Json, - jsonapi: Grape::Parser::Json, - xml: Grape::Parser::Xml - } - end + module_function - def parsers(**options) - builtin_parsers.merge(default_elements).merge!(options[:parsers] || {}) - end + def parser_for(format, parsers = nil) + return parsers[format] if parsers&.key?(format) - def parser_for(api_format, **options) - spec = parsers(**options)[api_format] - case spec - when nil - nil - when Symbol - method(spec) - else - spec - end - end + registry[format] end end end diff --git a/lib/grape/parser/base.rb b/lib/grape/parser/base.rb new file mode 100644 index 000000000..56640d2e5 --- /dev/null +++ b/lib/grape/parser/base.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Grape + module Parser + class Base + def self.call(_object, _env) + raise NotImplementedError + end + + def self.inherited(klass) + super + Parser.register(klass) + end + end + end +end diff --git a/lib/grape/parser/json.rb b/lib/grape/parser/json.rb index 7f72c9a94..a808999cc 100644 --- a/lib/grape/parser/json.rb +++ b/lib/grape/parser/json.rb @@ -2,14 +2,12 @@ module Grape module Parser - module Json - class << self - def call(object, _env) - ::Grape::Json.load(object) - rescue ::Grape::Json::ParseError - # handle JSON parsing errors via the rescue handlers or provide error message - raise Grape::Exceptions::InvalidMessageBody, 'application/json' - end + class Json < Base + def self.call(object, _env) + ::Grape::Json.load(object) + rescue ::Grape::Json::ParseError + # handle JSON parsing errors via the rescue handlers or provide error message + raise Grape::Exceptions::InvalidMessageBody.new('application/json') end end end diff --git a/lib/grape/parser/xml.rb b/lib/grape/parser/xml.rb index 20cde6e27..bdb2a485f 100644 --- a/lib/grape/parser/xml.rb +++ b/lib/grape/parser/xml.rb @@ -2,14 +2,12 @@ module Grape module Parser - module Xml - class << self - def call(object, _env) - ::Grape::Xml.parse(object) - rescue ::Grape::Xml::ParseError - # handle XML parsing errors via the rescue handlers or provide error message - raise Grape::Exceptions::InvalidMessageBody, 'application/xml' - end + class Xml < Base + def self.call(object, _env) + ::Grape::Xml.parse(object) + rescue ::Grape::Xml::ParseError + # handle XML parsing errors via the rescue handlers or provide error message + raise Grape::Exceptions::InvalidMessageBody.new('application/xml') end end end diff --git a/lib/grape/path.rb b/lib/grape/path.rb index e36684627..b4e2570de 100644 --- a/lib/grape/path.rb +++ b/lib/grape/path.rb @@ -1,97 +1,76 @@ # frozen_string_literal: true -require 'grape/util/cache' - module Grape # Represents a path to an endpoint. class Path - def self.prepare(raw_path, namespace, settings) - Path.new(raw_path, namespace, settings) - end + DEFAULT_FORMAT_SEGMENT = '(/.:format)' + NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT = '(.:format)' + VERSION_SEGMENT = ':version' - attr_reader :raw_path, :namespace, :settings + attr_reader :origin, :suffix - def initialize(raw_path, namespace, settings) - @raw_path = raw_path - @namespace = namespace - @settings = settings + def initialize(raw_path, raw_namespace, settings) + @origin = PartsCache[build_parts(raw_path, raw_namespace, settings)] + @suffix = build_suffix(raw_path, raw_namespace, settings) end - def mount_path - settings[:mount_path] + def to_s + "#{origin}#{suffix}" end - def root_prefix - split_setting(:root_prefix) - end + private - def uses_specific_format? - if settings.key?(:format) && settings.key?(:content_types) - (settings[:format] && Array(settings[:content_types]).size == 1) + def build_suffix(raw_path, raw_namespace, settings) + if uses_specific_format?(settings) + "(.#{settings[:format]})" + elsif !uses_path_versioning?(settings) || (valid_part?(raw_namespace) || valid_part?(raw_path)) + NO_VERSIONING_WITH_VALID_PATH_FORMAT_SEGMENT else - false + DEFAULT_FORMAT_SEGMENT end end - def uses_path_versioning? - if settings.key?(:version) && settings[:version_options] && settings[:version_options].key?(:using) - (settings[:version] && settings[:version_options][:using] == :path) - else - false + def build_parts(raw_path, raw_namespace, settings) + [].tap do |parts| + add_part(parts, settings[:mount_path]) + add_part(parts, settings[:root_prefix]) + parts << VERSION_SEGMENT if uses_path_versioning?(settings) + add_part(parts, raw_namespace) + add_part(parts, raw_path) end end - def namespace? - namespace&.match?(/^\S/) && namespace != '/' + def add_part(parts, value) + parts << value if value && not_slash?(value) end - def path? - raw_path&.match?(/^\S/) && raw_path != '/' + def not_slash?(value) + value != '/' end - def suffix - if uses_specific_format? - "(.#{settings[:format]})" - elsif !uses_path_versioning? || (namespace? || path?) - '(.:format)' - else - '(/.:format)' - end - end + def uses_specific_format?(settings) + return false unless settings.key?(:format) && settings.key?(:content_types) - def path - Grape::Router.normalize_path(PartsCache[parts]) + settings[:format] && Array(settings[:content_types]).size == 1 end - def path_with_suffix - "#{path}#{suffix}" - end + def uses_path_versioning?(settings) + return false unless settings.key?(:version) && settings[:version_options]&.key?(:using) - def to_s - path_with_suffix + settings[:version] && settings[:version_options][:using] == :path end - private + def valid_part?(part) + part&.match?(/^\S/) && not_slash?(part) + end class PartsCache < Grape::Util::Cache def initialize + super @cache = Hash.new do |h, parts| - h[parts] = -parts.join('/') + h[parts] = Grape::Router.normalize_path(parts.join('/')) end end end - - def parts - parts = [mount_path, root_prefix].compact - parts << ':version' if uses_path_versioning? - parts << namespace.to_s - parts << raw_path.to_s - parts.flatten.reject { |part| part == '/' } - end - - def split_setting(key) - return if settings[key].nil? - settings[key].to_s.split('/') - end end end diff --git a/lib/grape/railtie.rb b/lib/grape/railtie.rb new file mode 100644 index 000000000..42eb3442c --- /dev/null +++ b/lib/grape/railtie.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Grape + class Railtie < ::Rails::Railtie + initializer 'grape.deprecator' do |app| + app.deprecators[:grape] = Grape.deprecator + end + end +end diff --git a/lib/grape/request.rb b/lib/grape/request.rb index b54779748..997edac7c 100644 --- a/lib/grape/request.rb +++ b/lib/grape/request.rb @@ -1,48 +1,190 @@ # frozen_string_literal: true -require 'grape/util/lazy_object' - module Grape class Request < Rack::Request - HTTP_PREFIX = 'HTTP_' + # Based on rack 3 KNOWN_HEADERS + # https://github.com/rack/rack/blob/4f15e7b814922af79605be4b02c5b7c3044ba206/lib/rack/headers.rb#L10 + + KNOWN_HEADERS = %w[ + Accept + Accept-CH + Accept-Encoding + Accept-Language + Accept-Patch + Accept-Ranges + Accept-Version + Access-Control-Allow-Credentials + Access-Control-Allow-Headers + Access-Control-Allow-Methods + Access-Control-Allow-Origin + Access-Control-Expose-Headers + Access-Control-Max-Age + Age + Allow + Alt-Svc + Authorization + Cache-Control + Client-Ip + Connection + Content-Disposition + Content-Encoding + Content-Language + Content-Length + Content-Location + Content-MD5 + Content-Range + Content-Security-Policy + Content-Security-Policy-Report-Only + Content-Type + Cookie + Date + Delta-Base + Dnt + ETag + Expect-CT + Expires + Feature-Policy + Forwarded + Host + If-Modified-Since + If-None-Match + IM + Last-Modified + Link + Location + NEL + P3P + Permissions-Policy + Pragma + Preference-Applied + Proxy-Authenticate + Public-Key-Pins + Range + Referer + Referrer-Policy + Refresh + Report-To + Retry-After + Sec-Fetch-Dest + Sec-Fetch-Mode + Sec-Fetch-Site + Sec-Fetch-User + Server + Set-Cookie + Status + Strict-Transport-Security + Timing-Allow-Origin + Tk + Trailer + Transfer-Encoding + Upgrade + Upgrade-Insecure-Requests + User-Agent + Vary + Version + Via + Warning + WWW-Authenticate + X-Accel-Buffering + X-Accel-Charset + X-Accel-Expires + X-Accel-Limit-Rate + X-Accel-Mapping + X-Accel-Redirect + X-Access-Token + X-Auth-Request-Access-Token + X-Auth-Request-Email + X-Auth-Request-Groups + X-Auth-Request-Preferred-Username + X-Auth-Request-Redirect + X-Auth-Request-Token + X-Auth-Request-User + X-Cascade + X-Client-Ip + X-Content-Duration + X-Content-Security-Policy + X-Content-Type-Options + X-Correlation-Id + X-Download-Options + X-Forwarded-Access-Token + X-Forwarded-Email + X-Forwarded-For + X-Forwarded-Groups + X-Forwarded-Host + X-Forwarded-Port + X-Forwarded-Preferred-Username + X-Forwarded-Proto + X-Forwarded-Scheme + X-Forwarded-Ssl + X-Forwarded-Uri + X-Forwarded-User + X-Frame-Options + X-HTTP-Method-Override + X-Permitted-Cross-Domain-Policies + X-Powered-By + X-Real-IP + X-Redirect-By + X-Request-Id + X-Requested-With + X-Runtime + X-Sendfile + X-Sendfile-Type + X-UA-Compatible + X-WebKit-CS + X-XSS-Protection + ].each_with_object({}) do |header, response| + response["HTTP_#{header.upcase.tr('-', '_')}"] = header + end.freeze alias rack_params params + alias rack_cookies cookies - def initialize(env, **options) - extend options[:build_params_with] || Grape.config.param_builder + def initialize(env, build_params_with: nil) super(env) + @params_builder = Grape::ParamsBuilder.params_builder_for(build_params_with || Grape.config.param_builder) end def params - @params ||= build_params + @params ||= make_params end def headers @headers ||= build_headers end - private + def cookies + @cookies ||= Grape::Cookies.new(-> { rack_cookies }) + end + # needs to be public until extensions param_builder are removed def grape_routing_args - args = env[Grape::Env::GRAPE_ROUTING_ARGS].dup # preserve version from query string parameters - args.delete(:version) - args.delete(:route_info) - args + env[Grape::Env::GRAPE_ROUTING_ARGS]&.except(:version, :route_info) || {} end - def build_headers - Grape::Util::LazyObject.new do - env.each_pair.with_object({}) do |(k, v), headers| - next unless k.to_s.start_with? HTTP_PREFIX - transformed_header = Grape::Http::Headers::HTTP_HEADERS[k] || transform_header(k) - headers[transformed_header] = v - end - end + private + + def make_params + @params_builder.call(rack_params).deep_merge!(grape_routing_args) + rescue EOFError + raise Grape::Exceptions::EmptyMessageBody.new(content_type) + rescue Rack::Multipart::MultipartPartLimitError, Rack::Multipart::MultipartTotalPartLimitError + raise Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit) + rescue Rack::QueryParser::ParamsTooDeepError + raise Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit) + rescue Rack::Utils::ParameterTypeError + raise Grape::Exceptions::ConflictingTypes + rescue Rack::Utils::InvalidParameterError + raise Grape::Exceptions::InvalidParameters end - def transform_header(header) - -header[5..-1].split('_').each(&:capitalize!).join('-') + def build_headers + each_header.with_object(Grape::Util::Header.new) do |(k, v), headers| + next unless k.start_with? 'HTTP_' + + transformed_header = KNOWN_HEADERS.fetch(k) { -k[5..].tr('_', '-').downcase } + headers[transformed_header] = v + end end end end diff --git a/lib/grape/router.rb b/lib/grape/router.rb index 8c5dce872..6a9e3f5df 100644 --- a/lib/grape/router.rb +++ b/lib/grape/router.rb @@ -1,22 +1,33 @@ # frozen_string_literal: true -require 'grape/router/route' -require 'grape/util/cache' - module Grape class Router attr_reader :map, :compiled + # Taken from Rails + # normalize_path("/foo") # => "/foo" + # normalize_path("/foo/") # => "/foo" + # normalize_path("foo") # => "/foo" + # normalize_path("") # => "/" + # normalize_path("/%ab") # => "/%AB" + # https://github.com/rails/rails/blob/00cc4ff0259c0185fe08baadaa40e63ea2534f6e/actionpack/lib/action_dispatch/journey/router/utils.rb#L19 def self.normalize_path(path) + return +'/' unless path + + # Fast path for the overwhelming majority of paths that don't need to be normalized + return path.dup if path == '/' || (path.start_with?('/') && !(path.end_with?('/') || path.match?(%r{%|//}))) + + # Slow path + encoding = path.encoding path = +"/#{path}" path.squeeze!('/') - path.sub!(%r{/+\Z}, '') - path = '/' if path == '' - path - end - def self.supported_methods - @supported_methods ||= Grape::Http::Headers::SUPPORTED_METHODS + ['*'] + unless path == '/' + path.delete_suffix!('/') + path.gsub!(/(%[a-f0-9]{2})/) { ::Regexp.last_match(1).upcase } + end + + path.force_encoding(encoding) end def initialize @@ -28,15 +39,15 @@ def initialize def compile! return if compiled + @union = Regexp.union(@neutral_regexes) @neutral_regexes = nil - self.class.supported_methods.each do |method| + (Grape::HTTP_SUPPORTED_METHODS + ['*']).each do |method| + next unless map.key?(method) + routes = map[method] - @optimized_map[method] = routes.map.with_index do |route, index| - route.index = index - Regexp.new("(?<_#{index}>#{route.pattern.to_regexp})") - end - @optimized_map[method] = Regexp.union(@optimized_map[method]) + optimized_map = routes.map.with_index { |route, index| route.to_regexp(index) } + @optimized_map[method] = Regexp.union(optimized_map) end @compiled = true end @@ -45,9 +56,11 @@ def append(route) map[route.request_method] << route end - def associate_routes(pattern, **options) - @neutral_regexes << Regexp.new("(?<_#{@neutral_map.length}>)#{pattern.to_regexp}") - @neutral_map << Grape::Router::AttributeTranslator.new(**options, pattern: pattern, index: @neutral_map.length) + def associate_routes(pattern, options) + Grape::Router::GreedyRoute.new(pattern, options).then do |greedy_route| + @neutral_regexes << greedy_route.to_regexp(@neutral_map.length) + @neutral_map << greedy_route + end end def call(env) @@ -60,6 +73,7 @@ def call(env) def recognize_path(input) any = with_optimization { greedy_match?(input) } return if any == default_response + any.endpoint end @@ -80,6 +94,7 @@ def rotation(env, exact_route = nil) map[method].each do |route| next if exact_route == route next unless route.match?(input) + response = process_route(route, env) break unless cascade?(response) end @@ -88,25 +103,33 @@ def rotation(env, exact_route = nil) def transaction(env) input, method = *extract_input_and_method(env) - response = yield(input, method) - return response if response && !(cascade = cascade?(response)) + # using a Proc is important since `return` will exit the enclosing function + cascade_or_return_response = proc do |response| + if response + cascade?(response).tap do |cascade| + return response unless cascade + + # we need to close the body if possible before dismissing + response[2].try(:close) + end + end + end + + last_response_cascade = cascade_or_return_response.call(yield(input, method)) last_neighbor_route = greedy_match?(input) # If last_neighbor_route exists and request method is OPTIONS, # return response by using #call_with_allow_headers. - return call_with_allow_headers(env, last_neighbor_route) if last_neighbor_route && method == Grape::Http::Headers::OPTIONS && !cascade + return call_with_allow_headers(env, last_neighbor_route) if last_neighbor_route && method == Rack::OPTIONS && !last_response_cascade route = match?(input, '*') - return last_neighbor_route.endpoint.call(env) if last_neighbor_route && cascade && route + return last_neighbor_route.options[:endpoint].call(env) if last_neighbor_route && last_response_cascade && route - if route - response = process_route(route, env) - return response if response && !(cascade = cascade?(response)) - end + last_response_cascade = cascade_or_return_response.call(process_route(route, env)) if route - return call_with_allow_headers(env, last_neighbor_route) if !cascade && last_neighbor_route + return call_with_allow_headers(env, last_neighbor_route) if !last_response_cascade && last_neighbor_route nil end @@ -118,12 +141,12 @@ def process_route(route, env) def make_routing_args(default_args, route, input) args = default_args || { route_info: route } - args.merge(route.params(input) || {}) + args.merge(route.params(input)) end def extract_input_and_method(env) - input = string_for(env[Grape::Http::Headers::PATH_INFO]) - method = env[Grape::Http::Headers::REQUEST_METHOD] + input = string_for(env[Rack::PATH_INFO]) + method = env[Rack::REQUEST_METHOD] [input, method] end @@ -133,26 +156,22 @@ def with_optimization end def default_response - [404, { Grape::Http::Headers::X_CASCADE => 'pass' }, ['404 Not Found']] + headers = Grape::Util::Header.new.merge('X-Cascade' => 'pass') + [404, headers, ['404 Not Found']] end def match?(input, method) - current_regexp = @optimized_map[method] - return unless current_regexp.match(input) - last_match = Regexp.last_match - @map[method].detect { |route| last_match["_#{route.index}"] } + @optimized_map[method].match(input) { |m| @map[method].detect { |route| m[route.regexp_capture_index] } } end def greedy_match?(input) - return unless @union.match(input) - last_match = Regexp.last_match - @neutral_map.detect { |route| last_match["_#{route.index}"] } + @union.match(input) { |m| @neutral_map.detect { |route| m[route.regexp_capture_index] } } end def call_with_allow_headers(env, route) prepare_env_from_route(env, route) - env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.allow_header.join(', ').freeze - route.endpoint.call(env) + env[Grape::Env::GRAPE_ALLOWED_METHODS] = route.options[:allow_header] + route.options[:endpoint].call(env) end def prepare_env_from_route(env, route) @@ -161,7 +180,7 @@ def prepare_env_from_route(env, route) end def cascade?(response) - response && response[1][Grape::Http::Headers::X_CASCADE] == 'pass' + response && response[1]['X-Cascade'] == 'pass' end def string_for(input) diff --git a/lib/grape/router/attribute_translator.rb b/lib/grape/router/attribute_translator.rb deleted file mode 100644 index 88003887c..000000000 --- a/lib/grape/router/attribute_translator.rb +++ /dev/null @@ -1,63 +0,0 @@ -# frozen_string_literal: true - -module Grape - class Router - # this could be an OpenStruct, but doesn't work in Ruby 2.3.0, see https://bugs.ruby-lang.org/issues/12251 - class AttributeTranslator - attr_reader :attributes, :request_method, :requirements - - ROUTE_ATTRIBUTES = %i[ - prefix - version - settings - format - description - http_codes - headers - entity - details - requirements - request_method - namespace - ].freeze - - ROUTER_ATTRIBUTES = %i[pattern index].freeze - - def initialize(**attributes) - @attributes = attributes - end - - (ROUTER_ATTRIBUTES + ROUTE_ATTRIBUTES).each do |attr| - define_method attr do - attributes[attr] - end - end - - def to_h - attributes - end - - def method_missing(method_name, *args) - if setter?(method_name[-1]) - attributes[method_name[0..-1]] = *args - else - attributes[method_name] - end - end - - def respond_to_missing?(method_name, _include_private = false) - if setter?(method_name[-1]) - true - else - @attributes.key?(method_name) - end - end - - private - - def setter?(method_name) - method_name[-1] == '=' - end - end - end -end diff --git a/lib/grape/router/base_route.rb b/lib/grape/router/base_route.rb new file mode 100644 index 000000000..86439e908 --- /dev/null +++ b/lib/grape/router/base_route.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Grape + class Router + class BaseRoute + delegate_missing_to :@options + + attr_reader :index, :pattern, :options + + def initialize(options) + @options = options.is_a?(ActiveSupport::OrderedOptions) ? options : ActiveSupport::OrderedOptions.new.update(options) + end + + alias attributes options + + def regexp_capture_index + CaptureIndexCache[index] + end + + def pattern_regexp + pattern.to_regexp + end + + def to_regexp(index) + @index = index + Regexp.new("(?<#{regexp_capture_index}>#{pattern_regexp})") + end + + class CaptureIndexCache < Grape::Util::Cache + def initialize + super + @cache = Hash.new do |h, index| + h[index] = "_#{index}" + end + end + end + end + end +end diff --git a/lib/grape/router/greedy_route.rb b/lib/grape/router/greedy_route.rb new file mode 100644 index 000000000..c2fbcf8e8 --- /dev/null +++ b/lib/grape/router/greedy_route.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +# Act like a Grape::Router::Route but for greedy_match +# see @neutral_map + +module Grape + class Router + class GreedyRoute < BaseRoute + def initialize(pattern, options) + @pattern = pattern + super(options) + end + + # Grape::Router:Route defines params as a function + def params(_input = nil) + options[:params] || {} + end + end + end +end diff --git a/lib/grape/router/pattern.rb b/lib/grape/router/pattern.rb index e8c108ad8..4529a9271 100644 --- a/lib/grape/router/pattern.rb +++ b/lib/grape/router/pattern.rb @@ -1,62 +1,76 @@ # frozen_string_literal: true -require 'forwardable' -require 'mustermann/grape' -require 'grape/util/cache' - module Grape class Router class Pattern - DEFAULT_PATTERN_OPTIONS = { uri_decode: true }.freeze - DEFAULT_SUPPORTED_CAPTURE = %i[format version].freeze + extend Forwardable + + DEFAULT_CAPTURES = %w[format version].freeze attr_reader :origin, :path, :pattern, :to_regexp - extend Forwardable - def_delegators :pattern, :named_captures, :params + def_delegators :pattern, :params def_delegators :to_regexp, :=== alias match? === - def initialize(pattern, **options) - @origin = pattern - @path = build_path(pattern, **options) - @pattern = Mustermann::Grape.new(@path, **pattern_options(options)) + def initialize(origin, suffix, options) + @origin = origin + @path = build_path(origin, options[:anchor], suffix) + @pattern = build_pattern(@path, options[:params], options[:format], options[:version], options[:requirements]) @to_regexp = @pattern.to_regexp end + def captures_default + to_regexp.names + .delete_if { |n| DEFAULT_CAPTURES.include?(n) } + .to_h { |k| [k, ''] } + end + private - def pattern_options(options) - capture = extract_capture(**options) - options = DEFAULT_PATTERN_OPTIONS.dup - options[:capture] = capture if capture.present? - options + def build_pattern(path, params, format, version, requirements) + Mustermann::Grape.new( + path, + uri_decode: true, + params: params, + capture: extract_capture(format, version, requirements) + ) + end + + def build_path(pattern, anchor, suffix) + PatternCache[[build_path_from_pattern(pattern, anchor), suffix]] end - def build_path(pattern, anchor: false, suffix: nil, **_options) - unless anchor || pattern.end_with?('*path') - pattern = +pattern - pattern << '/' unless pattern.end_with?('/') - pattern << '*path' + def extract_capture(format, version, requirements) + capture = {}.tap do |h| + h[:format] = map_str(format) if format.present? + h[:version] = map_str(version) if version.present? end - pattern = -pattern.split('/').tap do |parts| - parts[parts.length - 1] = '?' + parts.last - end.join('/') if pattern.end_with?('*path') + return capture if requirements.blank? - PatternCache[[pattern, suffix]] + requirements.merge(capture) end - def extract_capture(requirements: {}, **options) - requirements = {}.merge(requirements) - DEFAULT_SUPPORTED_CAPTURE.each_with_object(requirements) do |field, capture| - option = Array(options[field]) - capture[field] = option.map(&:to_s) if option.present? + def build_path_from_pattern(pattern, anchor) + if pattern.end_with?('*path') + pattern.dup.insert(pattern.rindex('/') + 1, '?') + elsif anchor + pattern + elsif pattern.end_with?('/') + "#{pattern}?*path" + else + "#{pattern}/?*path" end end + def map_str(value) + Array.wrap(value).map(&:to_s) + end + class PatternCache < Grape::Util::Cache def initialize + super @cache = Hash.new do |h, (pattern, suffix)| h[[pattern, suffix]] = -"#{pattern}#{suffix}" end diff --git a/lib/grape/router/route.rb b/lib/grape/router/route.rb index fa940c925..64105be9c 100644 --- a/lib/grape/router/route.rb +++ b/lib/grape/router/route.rb @@ -1,57 +1,26 @@ # frozen_string_literal: true -require 'grape/router/pattern' -require 'grape/router/attribute_translator' -require 'forwardable' -require 'pathname' - module Grape class Router - class Route - ROUTE_ATTRIBUTE_REGEXP = /route_([_a-zA-Z]\w*)/.freeze - SOURCE_LOCATION_REGEXP = /^(.*?):(\d+?)(?::in `.+?')?$/.freeze - FIXED_NAMED_CAPTURES = %w[format version].freeze + class Route < BaseRoute + extend Forwardable - attr_accessor :pattern, :translator, :app, :index, :options + FORWARD_MATCH_METHOD = ->(input, pattern) { input.start_with?(pattern.origin) } + NON_FORWARD_MATCH_METHOD = ->(input, pattern) { pattern.match?(input) } - alias attributes translator + attr_reader :app, :request_method - extend Forwardable def_delegators :pattern, :path, :origin - delegate Grape::Router::AttributeTranslator::ROUTE_ATTRIBUTES => :attributes - - def method_missing(method_id, *arguments) - match = ROUTE_ATTRIBUTE_REGEXP.match(method_id.to_s) - if match - method_name = match.captures.last.to_sym - warn_route_methods(method_name, caller(1).shift) - @options[method_name] - else - super - end - end - - def respond_to_missing?(method_id, _) - ROUTE_ATTRIBUTE_REGEXP.match?(method_id.to_s) - end - - def route_method - warn_route_methods(:method, caller(1).shift, :request_method) - request_method - end - def route_path - warn_route_methods(:path, caller(1).shift) - pattern.path + def initialize(method, origin, path, options) + @request_method = upcase_method(method) + @pattern = Grape::Router::Pattern.new(origin, path, options) + @match_function = options[:forward_match] ? FORWARD_MATCH_METHOD : NON_FORWARD_MATCH_METHOD + super(options) end - def initialize(method, pattern, **options) - method_s = method.to_s - method_upcase = Grape::Http::Headers.find_supported_method(method_s) || method_s.upcase - - @options = options.merge(method: method_upcase) - @pattern = Pattern.new(pattern, **options) - @translator = AttributeTranslator.new(**options, request_method: method_upcase) + def convert_to_head_request! + @request_method = Rack::HEAD end def exec(env) @@ -64,29 +33,29 @@ def apply(app) end def match?(input) - translator.respond_to?(:forward_match) && translator.forward_match ? input.start_with?(pattern.origin) : pattern.match?(input) + return false if input.blank? + + @match_function.call(input, pattern) end def params(input = nil) - if input.nil? - pattern.named_captures.keys.each_with_object(translator.params) do |(key), defaults| - defaults[key] ||= '' unless FIXED_NAMED_CAPTURES.include?(key) || defaults.key?(key) - end - else - parsed = pattern.params(input) - parsed ? parsed.delete_if { |_, value| value.nil? }.symbolize_keys : {} - end + return params_without_input if input.blank? + + parsed = pattern.params(input) + return {} unless parsed + + parsed.compact.symbolize_keys end private - def warn_route_methods(name, location, expected = nil) - path, line = *location.scan(SOURCE_LOCATION_REGEXP).first - path = File.realpath(path) if Pathname.new(path).relative? - expected ||= name - warn <<-WARNING -#{path}:#{line}: The route_xxx methods such as route_#{name} have been deprecated, please use #{expected}. - WARNING + def params_without_input + @params_without_input ||= pattern.captures_default.merge(attributes.params) + end + + def upcase_method(method) + method_s = method.to_s + Grape::HTTP_SUPPORTED_METHODS.detect { |m| m.casecmp(method_s).zero? } || method_s.upcase end end end diff --git a/lib/grape/types/invalid_value.rb b/lib/grape/types/invalid_value.rb new file mode 100644 index 000000000..ae356daa1 --- /dev/null +++ b/lib/grape/types/invalid_value.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# only exists to make it shorter for external use +module Grape + module Types + InvalidValue = Class.new(Grape::Validations::Types::InvalidValue) + end +end diff --git a/lib/grape/util/base_inheritable.rb b/lib/grape/util/base_inheritable.rb index 5db6e3455..cc68ababf 100644 --- a/lib/grape/util/base_inheritable.rb +++ b/lib/grape/util/base_inheritable.rb @@ -26,10 +26,10 @@ def initialize_copy(other) def keys if new_values.any? - combined = inherited_values.keys - combined.concat(new_values.keys) - combined.uniq! - combined + inherited_values.keys.tap do |combined| + combined.concat(new_values.keys) + combined.uniq! + end else inherited_values.keys end diff --git a/lib/grape/util/cache.rb b/lib/grape/util/cache.rb index 3f51148f7..7514296c2 100644 --- a/lib/grape/util/cache.rb +++ b/lib/grape/util/cache.rb @@ -1,7 +1,4 @@ -# frozen_String_literal: true - -require 'singleton' -require 'forwardable' +# frozen_string_literal: true module Grape module Util diff --git a/lib/grape/util/endpoint_configuration.rb b/lib/grape/util/endpoint_configuration.rb index 90ad256f8..498556255 100644 --- a/lib/grape/util/endpoint_configuration.rb +++ b/lib/grape/util/endpoint_configuration.rb @@ -2,7 +2,7 @@ module Grape module Util - class EndpointConfiguration < LazyValueHash + class EndpointConfiguration < Lazy::ValueHash end end end diff --git a/lib/grape/util/header.rb b/lib/grape/util/header.rb new file mode 100644 index 000000000..632480798 --- /dev/null +++ b/lib/grape/util/header.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Grape + module Util + if Gem::Version.new(Rack.release) >= Gem::Version.new('3') + require 'rack/headers' + Header = Rack::Headers + else + require 'rack/utils' + Header = Rack::Utils::HeaderHash + end + end +end diff --git a/lib/grape/util/inheritable_setting.rb b/lib/grape/util/inheritable_setting.rb index 2a6024bca..b3504fe97 100644 --- a/lib/grape/util/inheritable_setting.rb +++ b/lib/grape/util/inheritable_setting.rb @@ -5,9 +5,7 @@ module Util # A branchable, inheritable settings object which can store both stackable # and inheritable values (see InheritableValues and StackableValues). class InheritableSetting - attr_accessor :route, :api_class, :namespace - attr_accessor :namespace_inheritable, :namespace_stackable, :namespace_reverse_stackable - attr_accessor :parent, :point_in_time_copies + attr_accessor :route, :api_class, :namespace, :namespace_inheritable, :namespace_stackable, :namespace_reverse_stackable, :parent, :point_in_time_copies # Retrieve global settings. def self.global diff --git a/lib/grape/util/inheritable_values.rb b/lib/grape/util/inheritable_values.rb index 6b43f8ca2..48cebb23a 100644 --- a/lib/grape/util/inheritable_values.rb +++ b/lib/grape/util/inheritable_values.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'base_inheritable' - module Grape module Util class InheritableValues < BaseInheritable diff --git a/lib/grape/util/lazy/block.rb b/lib/grape/util/lazy/block.rb new file mode 100644 index 000000000..a47d44b09 --- /dev/null +++ b/lib/grape/util/lazy/block.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Grape + module Util + module Lazy + class Block + def initialize(&new_block) + @block = new_block + end + + def evaluate_from(configuration) + @block.call(configuration) + end + + def evaluate + @block.call({}) + end + + def lazy? + true + end + + def to_s + evaluate.to_s + end + end + end + end +end diff --git a/lib/grape/util/lazy/value.rb b/lib/grape/util/lazy/value.rb new file mode 100644 index 000000000..59b799947 --- /dev/null +++ b/lib/grape/util/lazy/value.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Grape + module Util + module Lazy + class Value + attr_reader :access_keys + + def initialize(value, access_keys = []) + @value = value + @access_keys = access_keys + end + + def evaluate_from(configuration) + matching_lazy_value = configuration.fetch(@access_keys) + matching_lazy_value.evaluate + end + + def evaluate + @value + end + + def lazy? + true + end + + def reached_by(parent_access_keys, access_key) + @access_keys = parent_access_keys + [access_key] + self + end + + def to_s + evaluate.to_s + end + end + end + end +end diff --git a/lib/grape/util/lazy/value_array.rb b/lib/grape/util/lazy/value_array.rb new file mode 100644 index 000000000..b4c6a88ab --- /dev/null +++ b/lib/grape/util/lazy/value_array.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Grape + module Util + module Lazy + class ValueArray < ValueEnumerable + def initialize(array) + super + @value_hash = [] + array.each_with_index do |value, index| + self[index] = value + end + end + + def evaluate + @value_hash.map(&:evaluate) + end + end + end + end +end diff --git a/lib/grape/util/lazy/value_enumerable.rb b/lib/grape/util/lazy/value_enumerable.rb new file mode 100644 index 000000000..ce15693aa --- /dev/null +++ b/lib/grape/util/lazy/value_enumerable.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Grape + module Util + module Lazy + class ValueEnumerable < Value + def [](key) + if @value_hash[key].nil? + Value.new(nil).reached_by(access_keys, key) + else + @value_hash[key].reached_by(access_keys, key) + end + end + + def fetch(access_keys) + fetched_keys = access_keys.dup + value = self[fetched_keys.shift] + fetched_keys.any? ? value.fetch(fetched_keys) : value + end + + def []=(key, value) + @value_hash[key] = case value + when Hash + ValueHash.new(value) + when Array + ValueArray.new(value) + else + Value.new(value) + end + end + end + end + end +end diff --git a/lib/grape/util/lazy/value_hash.rb b/lib/grape/util/lazy/value_hash.rb new file mode 100644 index 000000000..c3447a0dd --- /dev/null +++ b/lib/grape/util/lazy/value_hash.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Grape + module Util + module Lazy + class ValueHash < ValueEnumerable + def initialize(hash) + super + @value_hash = ActiveSupport::HashWithIndifferentAccess.new + hash.each do |key, value| + self[key] = value + end + end + + def evaluate + @value_hash.transform_values(&:evaluate) + end + end + end + end +end diff --git a/lib/grape/util/lazy_block.rb b/lib/grape/util/lazy_block.rb deleted file mode 100644 index 6e7d18b8a..000000000 --- a/lib/grape/util/lazy_block.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Util - class LazyBlock - def initialize(&new_block) - @block = new_block - end - - def evaluate_from(configuration) - @block.call(configuration) - end - - def evaluate - @block.call({}) - end - - def lazy? - true - end - - def to_s - evaluate.to_s - end - end - end -end diff --git a/lib/grape/util/lazy_object.rb b/lib/grape/util/lazy_object.rb deleted file mode 100644 index 22ec7c440..000000000 --- a/lib/grape/util/lazy_object.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -# Based on https://github.com/HornsAndHooves/lazy_object - -module Grape - module Util - class LazyObject < BasicObject - attr_reader :callable - - def initialize(&callable) - @callable = callable - end - - def __target_object__ - @__target_object__ ||= callable.call - end - - def ==(other) - __target_object__ == other - end - - def !=(other) - __target_object__ != other - end - - def ! - !__target_object__ - end - - def method_missing(method_name, *args, &block) - if __target_object__.respond_to?(method_name) - __target_object__.send(method_name, *args, &block) - else - super - end - end - - def respond_to_missing?(method_name, include_priv = false) - __target_object__.respond_to?(method_name, include_priv) - end - end - end -end diff --git a/lib/grape/util/lazy_value.rb b/lib/grape/util/lazy_value.rb deleted file mode 100644 index b11364538..000000000 --- a/lib/grape/util/lazy_value.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Util - class LazyValue - attr_reader :access_keys - def initialize(value, access_keys = []) - @value = value - @access_keys = access_keys - end - - def evaluate_from(configuration) - matching_lazy_value = configuration.fetch(@access_keys) - matching_lazy_value.evaluate - end - - def evaluate - @value - end - - def lazy? - true - end - - def reached_by(parent_access_keys, access_key) - @access_keys = parent_access_keys + [access_key] - self - end - - def to_s - evaluate.to_s - end - end - - class LazyValueEnumerable < LazyValue - def [](key) - if @value_hash[key].nil? - LazyValue.new(nil).reached_by(access_keys, key) - else - @value_hash[key].reached_by(access_keys, key) - end - end - - def fetch(access_keys) - fetched_keys = access_keys.dup - value = self[fetched_keys.shift] - fetched_keys.any? ? value.fetch(fetched_keys) : value - end - - def []=(key, value) - @value_hash[key] = if value.is_a?(Hash) - LazyValueHash.new(value) - elsif value.is_a?(Array) - LazyValueArray.new(value) - else - LazyValue.new(value) - end - end - end - - class LazyValueArray < LazyValueEnumerable - def initialize(array) - super - @value_hash = [] - array.each_with_index do |value, index| - self[index] = value - end - end - - def evaluate - evaluated = [] - @value_hash.each_with_index do |value, index| - evaluated[index] = value.evaluate - end - evaluated - end - end - - class LazyValueHash < LazyValueEnumerable - def initialize(hash) - super - @value_hash = {}.with_indifferent_access - hash.each do |key, value| - self[key] = value - end - end - - def evaluate - evaluated = {}.with_indifferent_access - @value_hash.each do |key, value| - evaluated[key] = value.evaluate - end - evaluated - end - end - end -end diff --git a/lib/grape/util/media_type.rb b/lib/grape/util/media_type.rb new file mode 100644 index 000000000..1812fafa6 --- /dev/null +++ b/lib/grape/util/media_type.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Grape + module Util + class MediaType + attr_reader :type, :subtype, :vendor, :version, :format + + # based on the HTTP Accept header with the pattern: + # application/vnd.:vendor-:version+:format + VENDOR_VERSION_HEADER_REGEX = /\Avnd\.(?[a-z0-9.\-_!^]+?)(?:-(?[a-z0-9*.]+))?(?:\+(?[a-z0-9*\-.]+))?\z/.freeze + + def initialize(type:, subtype:) + @type = type + @subtype = subtype + VENDOR_VERSION_HEADER_REGEX.match(subtype) do |m| + @vendor = m[:vendor] + @version = m[:version] + @format = m[:format] + end + end + + def ==(other) + eql?(other) + end + + def eql?(other) + self.class == other.class && + other.type == type && + other.subtype == subtype && + other.vendor == vendor && + other.version == version && + other.format == format + end + + def hash + [self.class, type, subtype, vendor, version, format].hash + end + + class << self + def best_quality(header, available_media_types) + parse(best_quality_media_type(header, available_media_types)) + end + + def parse(media_type) + return if media_type.blank? + + type, subtype = media_type.split('/', 2) + return if type.blank? || subtype.blank? + + new(type: type, subtype: subtype) + end + + def match?(media_type) + return false if media_type.blank? + + subtype = media_type.split('/', 2).last + return false if subtype.blank? + + VENDOR_VERSION_HEADER_REGEX.match?(subtype) + end + + def best_quality_media_type(header, available_media_types) + header.blank? ? available_media_types.first : Rack::Utils.best_q_match(header, available_media_types) + end + end + + private_class_method :best_quality_media_type + end + end +end diff --git a/lib/grape/util/registrable.rb b/lib/grape/util/registrable.rb deleted file mode 100644 index e154456f8..000000000 --- a/lib/grape/util/registrable.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Util - module Registrable - def default_elements - @default_elements ||= {} - end - - def register(format, element) - default_elements[format] = element unless default_elements[format] - end - end - end -end diff --git a/lib/grape/util/registry.rb b/lib/grape/util/registry.rb new file mode 100644 index 000000000..6980445e6 --- /dev/null +++ b/lib/grape/util/registry.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Grape + module Util + module Registry + def register(klass) + short_name = build_short_name(klass) + return if short_name.nil? + + warn "#{short_name} is already registered with class #{klass}" if registry.key?(short_name) + registry[short_name] = klass + end + + private + + def build_short_name(klass) + return if klass.name.blank? + + klass.name.demodulize.underscore + end + + def registry + @registry ||= {}.with_indifferent_access + end + end + end +end diff --git a/lib/grape/util/reverse_stackable_values.rb b/lib/grape/util/reverse_stackable_values.rb index 171f390f7..43da1ead5 100644 --- a/lib/grape/util/reverse_stackable_values.rb +++ b/lib/grape/util/reverse_stackable_values.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'stackable_values' - module Grape module Util class ReverseStackableValues < StackableValues @@ -10,10 +8,7 @@ class ReverseStackableValues < StackableValues def concat_values(inherited_value, new_value) return inherited_value unless new_value - [].tap do |value| - value.concat(new_value) - value.concat(inherited_value) - end + new_value + inherited_value end end end diff --git a/lib/grape/util/stackable_values.rb b/lib/grape/util/stackable_values.rb index 01a568196..64336182c 100644 --- a/lib/grape/util/stackable_values.rb +++ b/lib/grape/util/stackable_values.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'base_inheritable' - module Grape module Util class StackableValues < BaseInheritable @@ -31,10 +29,7 @@ def to_hash def concat_values(inherited_value, new_value) return inherited_value unless new_value - [].tap do |value| - value.concat(inherited_value) - value.concat(new_value) - end + inherited_value + new_value end end end diff --git a/lib/grape/util/strict_hash_configuration.rb b/lib/grape/util/strict_hash_configuration.rb index 91fa41399..4ac105856 100644 --- a/lib/grape/util/strict_hash_configuration.rb +++ b/lib/grape/util/strict_hash_configuration.rb @@ -56,21 +56,20 @@ def self.simple_settings_methods(setting_name, new_config_class) def self.nested_settings_methods(setting_name, new_config_class) new_config_class.class_eval do setting_name.each_pair do |key, value| - define_method "#{key}_context" do + define_method :"#{key}_context" do @contexts[key] ||= Grape::Util::StrictHashConfiguration.config_class(*value).new end define_method key do |&block| - send("#{key}_context").instance_exec(&block) + send(:"#{key}_context").instance_exec(&block) end end - define_method 'to_hash' do - merge_hash = {} - setting_name.each_key { |k| merge_hash[k] = send("#{k}_context").to_hash } - + define_method :to_hash do @settings.to_hash.merge( - merge_hash + setting_name.each_key.with_object({}) do |k, merge_hash| + merge_hash[k] = send(:"#{k}_context").to_hash + end ) end end diff --git a/lib/grape/validations.rb b/lib/grape/validations.rb index bd55c0611..fd33071d0 100644 --- a/lib/grape/validations.rb +++ b/lib/grape/validations.rb @@ -1,24 +1,21 @@ # frozen_string_literal: true module Grape - # Registry to store and locate known Validators. module Validations - class << self - attr_accessor :validators - end + extend Grape::Util::Registry + + module_function - self.validators = {} + def require_validator(short_name) + raise Grape::Exceptions::UnknownValidator, short_name unless registry.key?(short_name) - # Register a new validator, so it can be used to validate parameters. - # @param short_name [String] all lower-case, no spaces - # @param klass [Class] the validator class. Should inherit from - # Validations::Base. - def self.register_validator(short_name, klass) - validators[short_name] = klass + registry[short_name] end - def self.deregister_validator(short_name) - validators.delete(short_name) + def build_short_name(klass) + return if klass.name.blank? + + klass.name.demodulize.underscore.delete_suffix('_validator') end end end diff --git a/lib/grape/validations/attributes_doc.rb b/lib/grape/validations/attributes_doc.rb new file mode 100644 index 000000000..c0d5ed954 --- /dev/null +++ b/lib/grape/validations/attributes_doc.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Grape + module Validations + # Documents parameters of an endpoint. If documentation isn't needed (for instance, it is an + # internal API), the class only cleans up attributes to avoid junk in RAM. + + class AttributesDoc + attr_accessor :type, :values + + # @param api [Grape::API::Instance] + # @param scope [Validations::ParamsScope] + def initialize(api, scope) + @api = api + @scope = scope + @type = type + end + + def extract_details(validations) + details[:required] = validations.key?(:presence) + + desc = validations.delete(:desc) || validations.delete(:description) + + details[:desc] = desc if desc + + documentation = validations.delete(:documentation) + + details[:documentation] = documentation if documentation + + details[:default] = validations[:default] if validations.key?(:default) + + details[:min_length] = validations[:length][:min] if validations.key?(:length) && validations[:length].key?(:min) + details[:max_length] = validations[:length][:max] if validations.key?(:length) && validations[:length].key?(:max) + end + + def document(attrs) + return if @api.namespace_inheritable(:do_not_document) + + details[:type] = type.to_s if type + details[:values] = values if values + + documented_attrs = attrs.each_with_object({}) do |name, memo| + memo[@scope.full_name(name)] = details + end + + @api.namespace_stackable(:params, documented_attrs) + end + + def required + details[:required] + end + + protected + + def details + @details ||= {} + end + end + end +end diff --git a/lib/grape/validations/attributes_iterator.rb b/lib/grape/validations/attributes_iterator.rb index 6c53d469a..97d7f0280 100644 --- a/lib/grape/validations/attributes_iterator.rb +++ b/lib/grape/validations/attributes_iterator.rb @@ -21,6 +21,7 @@ def each(&block) private def do_each(params_to_process, parent_indicies = [], &block) + @scope.reset_index # gets updated depending on the size of params_to_process params_to_process.each_with_index do |resource_params, index| # when we get arrays of arrays it means that target element located inside array # we need this because we want to know parent arrays indicies @@ -48,6 +49,14 @@ def do_each(params_to_process, parent_indicies = [], &block) def yield_attributes(_resource_params, _attrs) raise NotImplementedError end + + # This is a special case so that we can ignore tree's where option + # values are missing lower down. Unfortunately we can remove this + # are the parameter parsing stage as they are required to ensure + # the correct indexing is maintained + def skip?(val) + val == Grape::DSL::Parameters::EmptyOptionalValue + 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 000000000..218f47eec --- /dev/null +++ b/lib/grape/validations/contract_scope.rb @@ -0,0 +1,34 @@ +# 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: Grape::Validations.require_validator(:contract_scope), + opts: { schema: contract, fail_fast: false } + } + + api.namespace_stackable(:validations, validator_options) + end + end + end +end diff --git a/lib/grape/validations/multiple_attributes_iterator.rb b/lib/grape/validations/multiple_attributes_iterator.rb index 49c7c2bc6..e5621bcaa 100644 --- a/lib/grape/validations/multiple_attributes_iterator.rb +++ b/lib/grape/validations/multiple_attributes_iterator.rb @@ -6,7 +6,7 @@ class MultipleAttributesIterator < AttributesIterator private def yield_attributes(resource_params, _attrs) - yield resource_params + yield resource_params unless skip?(resource_params) end end end diff --git a/lib/grape/validations/params_scope.rb b/lib/grape/validations/params_scope.rb index 84e72f8bc..b80664e6f 100644 --- a/lib/grape/validations/params_scope.rb +++ b/lib/grape/validations/params_scope.rb @@ -4,15 +4,51 @@ module Grape module Validations class ParamsScope attr_accessor :element, :parent, :index - attr_reader :type + attr_reader :type, :params_meeting_dependency include Grape::DSL::Parameters + # There are a number of documentation options on entities that don't have + # corresponding validators. Since there is nowhere that enumerates them all, + # we maintain a list of them here and skip looking up validators for them. + RESERVED_DOCUMENTATION_KEYWORDS = %i[as required param_type is_array format example].freeze + + class Attr + attr_accessor :key, :scope + + # Open up a new ParamsScope::Attr + # @param key [Hash, Symbol] key of attr + # @param scope [Grape::Validations::ParamsScope] scope of attr + def initialize(key, scope) + @key = key + @scope = scope + end + + # @return Array[Symbol, Hash[Symbol => Array]] declared_params with symbol instead of Attr + def self.attrs_keys(declared_params) + declared_params.map do |declared_param_attr| + attr_key(declared_param_attr) + end + end + + def self.attr_key(declared_param_attr) + return attr_key(declared_param_attr.key) if declared_param_attr.is_a?(self) + + if declared_param_attr.is_a?(Hash) + declared_param_attr.transform_values { |value| attrs_keys(value) } + else + declared_param_attr + end + end + end + # Open up a new ParamsScope, allowing parameter definitions per # Grape::DSL::Params. # @param opts [Hash] options for this scope # @option opts :element [Symbol] the element that contains this scope; for # this to be relevant, @parent must be set + # @option opts :element_renamed [Symbol, nil] whenever this scope should + # be renamed and to what, given +nil+ no renaming is done # @option opts :parent [ParamsScope] the scope containing this scope # @option opts :api [API] the API endpoint to modify # @option opts :optional [Boolean] whether or not this scope needs to have @@ -23,23 +59,25 @@ class ParamsScope # validate if this param is present in the parent scope # @yield the instance context, open for parameter definitions def initialize(opts, &block) - @element = opts[:element] - @parent = opts[:parent] - @api = opts[:api] - @optional = opts[:optional] || false - @type = opts[:type] - @group = opts[:group] || {} - @dependent_on = opts[:dependent_on] + @element = opts[:element] + @element_renamed = opts[:element_renamed] + @parent = opts[:parent] + @api = opts[:api] + @optional = opts[:optional] || false + @type = opts[:type] + @group = opts[:group] + @dependent_on = opts[:dependent_on] + @params_meeting_dependency = [] @declared_params = [] @index = nil - instance_eval(&block) if block_given? + instance_eval(&block) if block configure_declared_params end def configuration - @api.configuration.evaluate + @api.configuration.respond_to?(:evaluate) ? @api.configuration.evaluate : @api.configuration end # @return [Boolean] whether or not this entire scope needs to be @@ -50,18 +88,32 @@ def should_validate?(parameters) return false if @optional && (scoped_params.blank? || all_element_blank?(scoped_params)) return false unless meets_dependency?(scoped_params, parameters) return true if parent.nil? + parent.should_validate?(parameters) end def meets_dependency?(params, request_params) - if @parent.present? && !@parent.meets_dependency?(@parent.params(request_params), request_params) - return false + return true unless @dependent_on + return false if @parent.present? && !@parent.meets_dependency?(@parent.params(request_params), request_params) + + if params.is_a?(Array) + @params_meeting_dependency = params.flatten.filter { |param| meets_dependency?(param, request_params) } + return @params_meeting_dependency.present? end + meets_hash_dependency?(params) + end + + def attr_meets_dependency?(params) return true unless @dependent_on - return params.any? { |param| meets_dependency?(param, request_params) } if params.is_a?(Array) - return false unless params.respond_to?(:with_indifferent_access) - params = params.with_indifferent_access + return false if @parent.present? && !@parent.attr_meets_dependency?(params) + + meets_hash_dependency?(params) + end + + def meets_hash_dependency?(params) + # params might be anything what looks like a hash, so it must implement a `key?` method + return false unless params.respond_to?(:key?) @dependent_on.each do |dependency| if dependency.is_a?(Hash) @@ -80,7 +132,7 @@ def meets_dependency?(params, request_params) def full_name(name, index: nil) if nested? # Find our containing element's name, and append ours. - "#{@parent.full_name(@element)}#{brackets(@index || index)}#{brackets(name)}" + "#{@parent.full_name(@element)}#{brackets(index || @index)}#{brackets(name)}" elsif lateral? # Find the name of the element as if it was at the same nesting level # as our parent. We need to forward our index upward to achieve this. @@ -119,37 +171,62 @@ def required? !@optional end + def reset_index + @index = nil + end + protected # Adds a parameter declaration to our list of validations. # @param attrs [Array] (see Grape::DSL::Parameters#requires) - def push_declared_params(attrs, **opts) - if lateral? - @parent.push_declared_params(attrs, **opts) - else - if opts && opts[:as] - @api.route_setting(:renamed_params, @api.route_setting(:renamed_params) || []) - @api.route_setting(:renamed_params) << { attrs.first => opts[:as] } - attrs = [opts[:as]] - end + def push_declared_params(attrs, opts = {}) + opts[:declared_params_scope] = self unless opts.key?(:declared_params_scope) + return @parent.push_declared_params(attrs, opts) if lateral? + + push_renamed_param(full_path + [attrs.first], opts[:as]) if opts[:as] + @declared_params.concat(attrs.map { |attr| ::Grape::Validations::ParamsScope::Attr.new(attr, opts[:declared_params_scope]) }) + end - @declared_params.concat attrs + # Get the full path of the parameter scope in the hierarchy. + # + # @return [Array] the nesting/path of the current parameter scope + def full_path + if nested? + (@parent.full_path + [@element]) + elsif lateral? + @parent.full_path + else + [] end end private + # Add a new parameter which should be renamed when using the +#declared+ + # method. + # + # @param path [Array] the full path of the parameter + # (including the parameter name as last array element) + # @param new_name [String, Symbol] the new name of the parameter (the + # renamed name, with the +as: ...+ semantic) + def push_renamed_param(path, new_name) + base = @api.route_setting(:renamed_params) || {} + base[Array(path).map(&:to_s)] = new_name.to_s + @api.route_setting(:renamed_params, base) + end + def require_required_and_optional_fields(context, opts) if context == :all - optional_fields = Array(opts[:except]) - required_fields = opts[:using].keys - optional_fields + optional_fields = Array.wrap(opts[:except]) + required_fields = opts[:using].keys.delete_if { |f| optional_fields.include?(f) } else # context == :none - required_fields = Array(opts[:except]) - optional_fields = opts[:using].keys - required_fields + required_fields = Array.wrap(opts[:except]) + optional_fields = opts[:using].keys.delete_if { |f| required_fields.include?(f) } end required_fields.each do |field| field_opts = opts[:using][field] raise ArgumentError, "required field not exist: #{field}" unless field_opts + requires(field, field_opts) end optional_fields.each do |field| @@ -160,7 +237,10 @@ def require_required_and_optional_fields(context, opts) def require_optional_fields(context, opts) optional_fields = opts[:using].keys - optional_fields -= Array(opts[:except]) unless context == :all + unless context == :all + except_fields = Array.wrap(opts[:except]) + optional_fields.delete_if { |f| except_fields.include?(f) } + end optional_fields.each do |field| field_opts = opts[:using][field] optional(field, field_opts) if field_opts @@ -184,16 +264,18 @@ def new_scope(attrs, optional = false, &block) # if required params are grouped and no type or unsupported type is provided, raise an error type = attrs[1] ? attrs[1][:type] : nil if attrs.first && !optional - raise Grape::Exceptions::MissingGroupTypeError.new if type.nil? - raise Grape::Exceptions::UnsupportedGroupTypeError.new unless Grape::Validations::Types.group?(type) + raise Grape::Exceptions::MissingGroupType if type.nil? + raise Grape::Exceptions::UnsupportedGroupType unless Grape::Validations::Types.group?(type) end self.class.new( - api: @api, - element: attrs[1][:as] || attrs.first, - parent: self, + api: @api, + element: attrs.first, + element_renamed: attrs[1][:as], + parent: self, optional: optional, - type: type || Array, + type: type || Array, + group: @group, &block ) end @@ -207,11 +289,11 @@ def new_scope(attrs, optional = false, &block) # @yield parameter scope def new_lateral_scope(options, &block) self.class.new( - api: @api, - element: nil, - parent: self, - options: @optional, - type: type == Array ? Array : Hash, + api: @api, + element: nil, + parent: self, + options: @optional, + type: type == Array ? Array : Hash, dependent_on: options[:dependent_on], &block ) @@ -223,16 +305,13 @@ def new_lateral_scope(options, &block) # `optional` invocation that opened this scope. # @yield parameter scope def new_group_scope(attrs, &block) - self.class.new( - api: @api, - parent: self, - group: attrs.first, - &block - ) + self.class.new(api: @api, parent: self, group: attrs.first, &block) end # Pushes declared params to parent or settings def configure_declared_params + push_renamed_param(full_path, @element_renamed) if @element_renamed + if nested? @parent.push_declared_params [element => @declared_params] else @@ -244,17 +323,14 @@ def configure_declared_params end def validates(attrs, validations) - doc_attrs = { required: validations.key?(:presence) } + doc = AttributesDoc.new @api, self + doc.extract_details validations coerce_type = infer_coercion(validations) - doc_attrs[:type] = coerce_type.to_s if coerce_type - - desc = validations.delete(:desc) || validations.delete(:description) - doc_attrs[:desc] = desc if desc + doc.type = coerce_type default = validations[:default] - doc_attrs[:default] = default if validations.key?(:default) if (values_hash = validations[:values]).is_a? Hash values = values_hash[:value] @@ -263,7 +339,8 @@ def validates(attrs, validations) else values = validations[:values] end - doc_attrs[:values] = values if values + + doc.values = values except_values = options_key?(:except_values, :value, validations) ? validations[:except_values][:value] : validations[:except_values] @@ -279,27 +356,23 @@ def validates(attrs, validations) # type should be compatible with values array, if both exist validate_value_coercion(coerce_type, values, except_values, excepts) - doc_attrs[:documentation] = validations.delete(:documentation) if validations.key?(:documentation) - - full_attrs = attrs.collect { |name| { name: name, full_name: full_name(name) } } - @api.document_attribute(full_attrs, doc_attrs) + doc.document attrs opts = derive_validator_options(validations) # Validate for presence before any other validators - if validations.key?(:presence) && validations[:presence] - validate('presence', validations[:presence], attrs, doc_attrs, opts) - validations.delete(:presence) - validations.delete(:message) if validations.key?(:message) - end + validates_presence(validations, attrs, doc, opts) # Before we run the rest of the validators, let's handle # whatever coercion so that we are working with correctly # type casted values - coerce_type validations, attrs, doc_attrs, opts + coerce_type validations, attrs, doc, opts validations.each do |type, options| - validate(type, options, attrs, doc_attrs, opts) + # Don't try to look up validators for documentation params that don't have one. + next if RESERVED_DOCUMENTATION_KEYWORDS.include?(type) + + validate(type, options, attrs, doc, opts) end end @@ -317,9 +390,7 @@ def validates(attrs, validations) # @return [class-like] type to which the parameter will be coerced # @raise [ArgumentError] if the given type options are invalid def infer_coercion(validations) - if validations.key?(:type) && validations.key?(:types) - raise ArgumentError, ':type may not be supplied with :types' - end + raise ArgumentError, ':type may not be supplied with :types' if validations.key?(:type) && validations.key?(:types) validations[:coerce] = (options_key?(:type, :value, validations) ? validations[:type][:value] : validations[:type]) if validations.key?(:type) validations[:coerce_message] = (options_key?(:type, :message, validations) ? validations[:type][:message] : nil) if validations.key?(:type) @@ -354,7 +425,8 @@ def check_coerce_with(validations) # but not special JSON types, which # already imply coercion method - return unless [JSON, Array[JSON]].include? validations[:coerce] + return if [JSON, Array[JSON]].exclude? validations[:coerce] + raise ArgumentError, 'coerce_with disallowed for type: JSON' end @@ -364,7 +436,7 @@ def check_coerce_with(validations) # composited from more than one +requires+/+optional+ # parameter, and needs to be run before most other # validations. - def coerce_type(validations, attrs, doc_attrs, opts) + def coerce_type(validations, attrs, doc, opts) check_coerce_with(validations) return unless validations.key?(:coerce) @@ -374,7 +446,7 @@ def coerce_type(validations, attrs, doc_attrs, opts) method: validations[:coerce_with], message: validations[:coerce_message] } - validate('coerce', coerce_options, attrs, doc_attrs, opts) + validate('coerce', coerce_options, attrs, doc, opts) validations.delete(:coerce_with) validations.delete(:coerce) validations.delete(:coerce_message) @@ -382,6 +454,7 @@ def coerce_type(validations, attrs, doc_attrs, opts) def guess_coerce_type(coerce_type, *values_list) return coerce_type unless coerce_type == Array + values_list.each do |values| next if !values || values.is_a?(Proc) return values.first.class if values.is_a?(Range) || !values.empty? @@ -392,14 +465,10 @@ def guess_coerce_type(coerce_type, *values_list) def check_incompatible_option_values(default, values, except_values, excepts) return unless default && !default.is_a?(Proc) - if values && !values.is_a?(Proc) - raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :values, values) \ - unless Array(default).all? { |def_val| values.include?(def_val) } - end + raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :values, values) if values && !values.is_a?(Proc) && !Array(default).all? { |def_val| values.include?(def_val) } - if except_values && !except_values.is_a?(Proc) - raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, except_values) \ - unless Array(default).none? { |def_val| except_values.include?(def_val) } + if except_values && !except_values.is_a?(Proc) && Array(default).any? { |def_val| except_values.include?(def_val) } + raise Grape::Exceptions::IncompatibleOptionValues.new(:default, default, :except, except_values) end return unless excepts && !excepts.is_a?(Proc) @@ -407,39 +476,34 @@ def check_incompatible_option_values(default, values, except_values, excepts) unless Array(default).none? { |def_val| excepts.include?(def_val) } end - def validate(type, options, attrs, doc_attrs, opts) - validator_class = Validations.validators[type.to_s] - - raise Grape::Exceptions::UnknownValidator.new(type) unless validator_class - + def validate(type, options, attrs, doc, opts) validator_options = { - attributes: attrs, - options: options, - required: doc_attrs[:required], - params_scope: self, - opts: opts, - validator_class: validator_class + attributes: attrs, + options: options, + required: doc.required, + params_scope: self, + opts: opts, + validator_class: Validations.require_validator(type) } @api.namespace_stackable(:validations, validator_options) end def validate_value_coercion(coerce_type, *values_list) return unless coerce_type - coerce_type = coerce_type.first if coerce_type.is_a?(Array) + + coerce_type = coerce_type.first if coerce_type.is_a?(Enumerable) values_list.each do |values| next if !values || values.is_a?(Proc) - value_types = values.is_a?(Range) ? [values.begin, values.end] : values - if coerce_type == Grape::API::Boolean - value_types = value_types.map { |type| Grape::API::Boolean.build(type) } - end - unless value_types.all? { |v| v.is_a? coerce_type } - raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) - end + + value_types = values.is_a?(Range) ? [values.begin, values.end].compact : values + value_types = value_types.map { |type| Grape::API::Boolean.build(type) } if coerce_type == Grape::API::Boolean + raise Grape::Exceptions::IncompatibleOptionValues.new(:type, coerce_type, :values, values) unless value_types.all?(coerce_type) end end def extract_message_option(attrs) return nil unless attrs.is_a?(Array) + opts = attrs.last.is_a?(Hash) ? attrs.pop : {} opts.key?(:message) && !opts[:message].nil? ? opts.delete(:message) : nil end @@ -459,9 +523,16 @@ def derive_validator_options(validations) { allow_blank: allow_blank.is_a?(Hash) ? allow_blank[:value] : allow_blank, - fail_fast: validations.delete(:fail_fast) || false + fail_fast: validations.delete(:fail_fast) || false } end + + def validates_presence(validations, attrs, doc, opts) + return unless validations.key?(:presence) && validations[:presence] + + validate('presence', validations.delete(:presence), attrs, doc, opts) + validations.delete(:message) if validations.key?(:message) + end end end end diff --git a/lib/grape/validations/single_attribute_iterator.rb b/lib/grape/validations/single_attribute_iterator.rb index f28159896..218f4037b 100644 --- a/lib/grape/validations/single_attribute_iterator.rb +++ b/lib/grape/validations/single_attribute_iterator.rb @@ -6,6 +6,8 @@ class SingleAttributeIterator < AttributesIterator private def yield_attributes(val, attrs) + return if skip?(val) + attrs.each do |attr_name| yield val, attr_name, empty?(val) end diff --git a/lib/grape/validations/types.rb b/lib/grape/validations/types.rb index a682286a5..86f9c9b60 100644 --- a/lib/grape/validations/types.rb +++ b/lib/grape/validations/types.rb @@ -1,13 +1,5 @@ # frozen_string_literal: true -require_relative 'types/build_coercer' -require_relative 'types/custom_type_coercer' -require_relative 'types/custom_type_collection_coercer' -require_relative 'types/multiple_type_coercer' -require_relative 'types/variant_collection_coercer' -require_relative 'types/json' -require_relative 'types/file' - module Grape module Validations # Module for code related to grape's system for @@ -21,11 +13,8 @@ module Validations # and {Grape::Dsl::Parameters#optional}. The main # entry point for this process is {Types.build_coercer}. module Types - # Instances of this class may be used as tokens to denote that - # a parameter value could not be coerced. - class InvalidValue; end + module_function - # Types representing a single value, which are coerced. PRIMITIVES = [ # Numerical Integer, @@ -47,33 +36,23 @@ class InvalidValue; end ].freeze # Types representing data structures. - STRUCTURES = [ - Hash, - Array, - Set - ].freeze + STRUCTURES = [Hash, Array, Set].freeze - # Special custom types provided by Grape. SPECIAL = { - JSON => Json, + ::JSON => Json, Array[JSON] => JsonArray, ::File => File, Rack::Multipart::UploadedFile => File }.freeze - GROUPS = [ - Array, - Hash, - JSON, - Array[JSON] - ].freeze + GROUPS = [Array, Hash, JSON, Array[JSON]].freeze # Is the given class a primitive type as recognized by Grape? # # @param type [Class] type to check # @return [Boolean] whether or not the type is known by Grape as a valid # type for a single value - def self.primitive?(type) + def primitive?(type) PRIMITIVES.include?(type) end @@ -83,7 +62,7 @@ def self.primitive?(type) # @param type [Class] type to check # @return [Boolean] whether or not the type is known by Grape as a valid # data structure type - def self.structure?(type) + def structure?(type) STRUCTURES.include?(type) end @@ -95,7 +74,7 @@ def self.structure?(type) # @param type [Array,Set] type (or type list!) to check # @return [Boolean] +true+ if the given value will be treated as # a list of types. - def self.multiple?(type) + def multiple?(type) (type.is_a?(Array) || type.is_a?(Set)) && type.size > 1 end @@ -106,7 +85,7 @@ def self.multiple?(type) # # @param type [Class] type to check # @return [Boolean] +true+ if special routines are available - def self.special?(type) + def special?(type) SPECIAL.key? type end @@ -115,7 +94,7 @@ def self.special?(type) # # @param type [Array,Class] type to check # @return [Boolean] +true+ if the type is a supported group type - def self.group?(type) + def group?(type) GROUPS.include? type end @@ -124,7 +103,7 @@ def self.group?(type) # # @param type [Class] type to check # @return [Boolean] whether or not the type can be used as a custom type - def self.custom?(type) + def custom?(type) !primitive?(type) && !structure?(type) && !multiple?(type) && @@ -137,15 +116,98 @@ def self.custom?(type) # @param type [Array,Class] type to check # @return [Boolean] true if +type+ is a collection of a type that implements # its own +#parse+ method. - def self.collection_of_custom?(type) + def collection_of_custom?(type) (type.is_a?(Array) || type.is_a?(Set)) && type.length == 1 && (custom?(type.first) || special?(type.first)) end - def self.map_special(type) + def map_special(type) SPECIAL.fetch(type, type) end + + # Chooses the best coercer for the given type. For example, if the type + # is Integer, it will return a coercer which will be able to coerce a value + # to the integer. + # + # There are a few very special coercers which might be returned. + # + # +Grape::Types::MultipleTypeCoercer+ is a coercer which is returned when + # the given type implies values in an array with different types. + # For example, +[Integer, String]+ allows integer and string values in + # an array. + # + # +Grape::Types::CustomTypeCoercer+ is a coercer which is returned when + # a method is specified by a user with +coerce_with+ option or the user + # specifies a custom type which implements requirments of + # +Grape::Types::CustomTypeCoercer+. + # + # +Grape::Types::CustomTypeCollectionCoercer+ is a very similar to the + # previous one, but it expects an array or set of values having a custom + # type implemented by the user. + # + # There is also a group of custom types implemented by Grape, check + # +Grape::Validations::Types::SPECIAL+ to get the full list. + # + # @param type [Class] the type to which input strings + # should be coerced + # @param method [Class,#call] the coercion method to use + # @return [Object] object to be used + # for coercion and type validation + def build_coercer(type, method: nil, strict: false) + cache_instance(type, method, strict) do + create_coercer_instance(type, method, strict) + end + end + + def create_coercer_instance(type, method, strict) + # Maps a custom type provided by Grape, it doesn't map types wrapped by collections!!! + type = Types.map_special(type) + + # Use a special coercer for multiply-typed parameters. + if Types.multiple?(type) + MultipleTypeCoercer.new(type, method) + + # Use a special coercer for custom types and coercion methods. + elsif method || Types.custom?(type) + CustomTypeCoercer.new(type, method) + + # Special coercer for collections of types that implement a parse method. + # CustomTypeCoercer (above) already handles such types when an explicit coercion + # method is supplied. + elsif Types.collection_of_custom?(type) + Types::CustomTypeCollectionCoercer.new( + Types.map_special(type.first), type.is_a?(Set) + ) + else + DryTypeCoercer.coercer_instance_for(type, strict) + end + end + + def cache_instance(type, method, strict, &_block) + key = cache_key(type, method, strict) + + return @__cache[key] if @__cache.key?(key) + + instance = yield + + @__cache_write_lock.synchronize do + @__cache[key] = instance + end + + instance + end + + def cache_key(type, method, strict) + [type, method, strict].each_with_object(+'_') do |val, memo| + next if val.nil? + + memo << '_' << val.to_s + end + end + + instance_variable_set(:@__cache, {}) + instance_variable_set(:@__cache_write_lock, Mutex.new) end end end diff --git a/lib/grape/validations/types/array_coercer.rb b/lib/grape/validations/types/array_coercer.rb index d3aeb2146..ec4ca41de 100644 --- a/lib/grape/validations/types/array_coercer.rb +++ b/lib/grape/validations/types/array_coercer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'dry_type_coercer' - module Grape module Validations module Types @@ -14,8 +12,6 @@ module Types # behavior of Virtus which was used earlier, a `Grape::Validations::Types::PrimitiveCoercer` # maintains Virtus behavior in coercing. class ArrayCoercer < DryTypeCoercer - register_collection Array - def initialize(type, strict = false) super diff --git a/lib/grape/validations/types/build_coercer.rb b/lib/grape/validations/types/build_coercer.rb deleted file mode 100644 index c55e048db..000000000 --- a/lib/grape/validations/types/build_coercer.rb +++ /dev/null @@ -1,94 +0,0 @@ -# frozen_string_literal: true - -require_relative 'array_coercer' -require_relative 'set_coercer' -require_relative 'primitive_coercer' - -module Grape - module Validations - module Types - # Chooses the best coercer for the given type. For example, if the type - # is Integer, it will return a coercer which will be able to coerce a value - # to the integer. - # - # There are a few very special coercers which might be returned. - # - # +Grape::Types::MultipleTypeCoercer+ is a coercer which is returned when - # the given type implies values in an array with different types. - # For example, +[Integer, String]+ allows integer and string values in - # an array. - # - # +Grape::Types::CustomTypeCoercer+ is a coercer which is returned when - # a method is specified by a user with +coerce_with+ option or the user - # specifies a custom type which implements requirments of - # +Grape::Types::CustomTypeCoercer+. - # - # +Grape::Types::CustomTypeCollectionCoercer+ is a very similar to the - # previous one, but it expects an array or set of values having a custom - # type implemented by the user. - # - # There is also a group of custom types implemented by Grape, check - # +Grape::Validations::Types::SPECIAL+ to get the full list. - # - # @param type [Class] the type to which input strings - # should be coerced - # @param method [Class,#call] the coercion method to use - # @return [Object] object to be used - # for coercion and type validation - def self.build_coercer(type, method: nil, strict: false) - cache_instance(type, method, strict) do - create_coercer_instance(type, method, strict) - end - end - - def self.create_coercer_instance(type, method, strict) - # Maps a custom type provided by Grape, it doesn't map types wrapped by collections!!! - type = Types.map_special(type) - - # Use a special coercer for multiply-typed parameters. - if Types.multiple?(type) - MultipleTypeCoercer.new(type, method) - - # Use a special coercer for custom types and coercion methods. - elsif method || Types.custom?(type) - CustomTypeCoercer.new(type, method) - - # Special coercer for collections of types that implement a parse method. - # CustomTypeCoercer (above) already handles such types when an explicit coercion - # method is supplied. - elsif Types.collection_of_custom?(type) - Types::CustomTypeCollectionCoercer.new( - Types.map_special(type.first), type.is_a?(Set) - ) - else - DryTypeCoercer.coercer_instance_for(type, strict) - end - end - - def self.cache_instance(type, method, strict, &_block) - key = cache_key(type, method, strict) - - return @__cache[key] if @__cache.key?(key) - - instance = yield - - @__cache_write_lock.synchronize do - @__cache[key] = instance - end - - instance - end - - def self.cache_key(type, method, strict) - [type, method, strict].each_with_object(+'_') do |val, memo| - next if val.nil? - - memo << '_' << val.to_s - end - end - - instance_variable_set(:@__cache, {}) - instance_variable_set(:@__cache_write_lock, Mutex.new) - end - end -end diff --git a/lib/grape/validations/types/custom_type_coercer.rb b/lib/grape/validations/types/custom_type_coercer.rb index f6a4e0cf9..b0a1e54be 100644 --- a/lib/grape/validations/types/custom_type_coercer.rb +++ b/lib/grape/validations/types/custom_type_coercer.rb @@ -52,10 +52,11 @@ def initialize(type, method = nil) # this should always be a string. # @return [Object] the coerced result def call(val) - return if val.nil? - coerced_val = @method.call(val) + + return coerced_val if coerced_val.is_a?(InvalidValue) return InvalidValue.new unless coerced?(coerced_val) + coerced_val end @@ -103,13 +104,25 @@ def infer_type_check(type) # passed, or if the type also implements a parse() method. type elsif type.is_a?(Enumerable) - ->(value) { value.respond_to?(:all?) && value.all? { |item| item.is_a? type[0] } } + lambda do |value| + value.is_a?(Enumerable) && value.all? do |val| + recursive_type_check(type.first, val) + end + end else # By default, do a simple type check ->(value) { value.is_a? type } end end + def recursive_type_check(type, value) + if type.is_a?(Enumerable) && value.is_a?(Enumerable) + value.all? { |val| recursive_type_check(type.first, val) } + else + !type.is_a?(Enumerable) && value.is_a?(type) + end + end + # Enforce symbolized keys for complex types # by wrapping the coercion method such that # any Hash objects in the immediate heirarchy @@ -128,7 +141,7 @@ def enforce_symbolized_keys(type, method) lambda do |val| method.call(val).tap do |new_val| new_val.map do |item| - item.is_a?(Hash) ? symbolize_keys(item) : item + item.is_a?(Hash) ? item.deep_symbolize_keys : item end end end @@ -136,7 +149,7 @@ def enforce_symbolized_keys(type, method) # Hash objects are processed directly elsif type == Hash lambda do |val| - symbolize_keys method.call(val) + method.call(val).deep_symbolize_keys end # Simple types are not processed. @@ -145,20 +158,6 @@ def enforce_symbolized_keys(type, method) method end end - - def symbolize_keys!(hash) - hash.each_key do |key| - hash[key.to_sym] = hash.delete(key) if key.respond_to?(:to_sym) - end - hash - end - - def symbolize_keys(hash) - hash.inject({}) do |new_hash, (key, value)| - new_key = key.respond_to?(:to_sym) ? key.to_sym : key - new_hash.merge!(new_key => value) - end - end end end end diff --git a/lib/grape/validations/types/dry_type_coercer.rb b/lib/grape/validations/types/dry_type_coercer.rb index 0a682e53e..f9672198e 100644 --- a/lib/grape/validations/types/dry_type_coercer.rb +++ b/lib/grape/validations/types/dry_type_coercer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'dry-types' - module DryTypes # Call +Dry.Types()+ to add all registered types to +DryTypes+ which is # a container in this case. Check documentation for more information @@ -18,34 +16,26 @@ module Types # https://dry-rb.org/gems/dry-types/1.2/built-in-types/ class DryTypeCoercer class << self - # Registers a collection coercer which could be found by a type, - # see +collection_coercer_for+ method below. This method is meant for inheritors. - def register_collection(type) - DryTypeCoercer.collection_coercers[type] = self - end - # Returns a collection coercer which corresponds to a given type. # Example: # # collection_coercer_for(Array) # #=> Grape::Validations::Types::ArrayCoercer def collection_coercer_for(type) - collection_coercers[type] + case type + when Array + ArrayCoercer + when Set + SetCoercer + else + raise ArgumentError, "Unknown type: #{type}" + end end # Returns an instance of a coercer for a given type def coercer_instance_for(type, strict = false) - return PrimitiveCoercer.new(type, strict) if type.class == Class - - # in case of a collection (Array[Integer]) the type is an instance of a collection, - # so we need to figure out the actual type - collection_coercer_for(type.class).new(type, strict) - end - - protected - - def collection_coercers - @collection_coercers ||= {} + klass = type.instance_of?(Class) ? PrimitiveCoercer : collection_coercer_for(type) + klass.new(type, strict) end end diff --git a/lib/grape/validations/types/invalid_value.rb b/lib/grape/validations/types/invalid_value.rb new file mode 100644 index 000000000..9744a285f --- /dev/null +++ b/lib/grape/validations/types/invalid_value.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Types + # Instances of this class may be used as tokens to denote that a parameter value could not be + # coerced. The given message will be used as a validation error. + class InvalidValue + attr_reader :message + + def initialize(message = nil) + @message = message + end + end + end + end +end diff --git a/lib/grape/validations/types/json.rb b/lib/grape/validations/types/json.rb index 25dded6f0..61b01131c 100644 --- a/lib/grape/validations/types/json.rb +++ b/lib/grape/validations/types/json.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'json' - module Grape module Validations module Types @@ -22,6 +20,7 @@ def parse(input) # Allow nulls and blank strings return if input.nil? || input.match?(/^\s*$/) + JSON.parse(input, symbolize_names: true) end @@ -41,7 +40,7 @@ def parsed?(value) # @param value [Object] result of {#parse} # @return [true,false] def coerced_collection?(value) - value.is_a?(::Array) && value.all? { |i| i.is_a? ::Hash } + value.is_a?(::Array) && value.all?(::Hash) end end end diff --git a/lib/grape/validations/types/primitive_coercer.rb b/lib/grape/validations/types/primitive_coercer.rb index 7b1e84180..e59b5c6eb 100644 --- a/lib/grape/validations/types/primitive_coercer.rb +++ b/lib/grape/validations/types/primitive_coercer.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_relative 'dry_type_coercer' - module Grape module Validations module Types @@ -11,15 +9,21 @@ module Types class PrimitiveCoercer < DryTypeCoercer MAPPING = { Grape::API::Boolean => DryTypes::Params::Bool, - BigDecimal => DryTypes::Params::Decimal, + BigDecimal => DryTypes::Params::Decimal, + Numeric => DryTypes::Params::Integer | DryTypes::Params::Float | DryTypes::Params::Decimal, + TrueClass => DryTypes::Params::Bool.constrained(eql: true), + FalseClass => DryTypes::Params::Bool.constrained(eql: false), # unfortunately, a +Params+ scope doesn't contain String - String => DryTypes::Coercible::String + String => DryTypes::Coercible::String }.freeze STRICT_MAPPING = { Grape::API::Boolean => DryTypes::Strict::Bool, - BigDecimal => DryTypes::Strict::Decimal + BigDecimal => DryTypes::Strict::Decimal, + Numeric => DryTypes::Strict::Integer | DryTypes::Strict::Float | DryTypes::Strict::Decimal, + TrueClass => DryTypes::Strict::Bool.constrained(eql: true), + FalseClass => DryTypes::Strict::Bool.constrained(eql: false) }.freeze def initialize(type, strict = false) @@ -27,11 +31,13 @@ def initialize(type, strict = false) @type = type - @coercer = if strict - STRICT_MAPPING.fetch(type) { scope.const_get(type.name) } - else - MAPPING.fetch(type) { scope.const_get(type.name) } - end + @coercer = (strict ? STRICT_MAPPING : MAPPING).fetch(type) do + scope.const_get(type.name, false) + rescue NameError + raise ArgumentError, "type #{type} should support coercion via `[]`" unless type.respond_to?(:[]) + + type + end end def call(val) diff --git a/lib/grape/validations/types/set_coercer.rb b/lib/grape/validations/types/set_coercer.rb index dc76fc773..9b1b311f8 100644 --- a/lib/grape/validations/types/set_coercer.rb +++ b/lib/grape/validations/types/set_coercer.rb @@ -1,16 +1,11 @@ # frozen_string_literal: true -require 'set' -require_relative 'array_coercer' - module Grape module Validations module Types # Takes the given array and converts it to a set. Every element of the set # is also coerced. class SetCoercer < ArrayCoercer - register_collection Set - def initialize(type, strict = false) super diff --git a/lib/grape/validations/validator_factory.rb b/lib/grape/validations/validator_factory.rb index f23655f10..0e2022d3a 100644 --- a/lib/grape/validations/validator_factory.rb +++ b/lib/grape/validations/validator_factory.rb @@ -3,7 +3,7 @@ module Grape module Validations class ValidatorFactory - def self.create_validator(**options) + def self.create_validator(options) options[:validator_class].new(options[:attributes], options[:options], options[:required], diff --git a/lib/grape/validations/validators/all_or_none.rb b/lib/grape/validations/validators/all_or_none.rb deleted file mode 100644 index 186361f0d..000000000 --- a/lib/grape/validations/validators/all_or_none.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'grape/validations/validators/multiple_params_base' - -module Grape - module Validations - class AllOrNoneOfValidator < MultipleParamsBase - def validate_params!(params) - keys = keys_in_common(params) - return if keys.empty? || keys.length == all_keys.length - raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:all_or_none)) - end - end - end -end diff --git a/lib/grape/validations/validators/all_or_none_of_validator.rb b/lib/grape/validations/validators/all_or_none_of_validator.rb new file mode 100644 index 000000000..2fe553a15 --- /dev/null +++ b/lib/grape/validations/validators/all_or_none_of_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class AllOrNoneOfValidator < MultipleParamsBase + def validate_params!(params) + keys = keys_in_common(params) + return if keys.empty? || keys.length == all_keys.length + + raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:all_or_none)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/allow_blank.rb b/lib/grape/validations/validators/allow_blank.rb deleted file mode 100644 index e212c273c..000000000 --- a/lib/grape/validations/validators/allow_blank.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class AllowBlankValidator < Base - def validate_param!(attr_name, params) - return if (options_key?(:value) ? @option[:value] : @option) || !params.is_a?(Hash) - - value = params[attr_name] - value = value.strip if value.respond_to?(:strip) - - return if value == false || value.present? - - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:blank)) - end - end - end -end diff --git a/lib/grape/validations/validators/allow_blank_validator.rb b/lib/grape/validations/validators/allow_blank_validator.rb new file mode 100644 index 000000000..b9954c1d8 --- /dev/null +++ b/lib/grape/validations/validators/allow_blank_validator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class AllowBlankValidator < Base + def validate_param!(attr_name, params) + return if (options_key?(:value) ? @option[:value] : @option) || !params.is_a?(Hash) + + value = params[attr_name] + value = value.scrub if value.respond_to?(:scrub) + + return if value == false || value.present? + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:blank)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/as.rb b/lib/grape/validations/validators/as.rb deleted file mode 100644 index 77cef5f1c..000000000 --- a/lib/grape/validations/validators/as.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class AsValidator < Base - def initialize(attrs, options, required, scope, **opts) - @renamed_options = options - super - end - - def validate_param!(attr_name, params) - params[@renamed_options] = params[attr_name] - end - end - end -end diff --git a/lib/grape/validations/validators/as_validator.rb b/lib/grape/validations/validators/as_validator.rb new file mode 100644 index 000000000..8a3d8db16 --- /dev/null +++ b/lib/grape/validations/validators/as_validator.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class AsValidator < Base + # We use a validator for renaming parameters. This is just a marker for + # the parameter scope to handle the renaming. No actual validation + # happens here. + def validate_param!(*); end + end + end + end +end diff --git a/lib/grape/validations/validators/at_least_one_of.rb b/lib/grape/validations/validators/at_least_one_of.rb deleted file mode 100644 index 001c784dd..000000000 --- a/lib/grape/validations/validators/at_least_one_of.rb +++ /dev/null @@ -1,14 +0,0 @@ -# frozen_string_literal: true - -require 'grape/validations/validators/multiple_params_base' - -module Grape - module Validations - class AtLeastOneOfValidator < MultipleParamsBase - def validate_params!(params) - return unless keys_in_common(params).empty? - raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:at_least_one)) - end - end - end -end diff --git a/lib/grape/validations/validators/at_least_one_of_validator.rb b/lib/grape/validations/validators/at_least_one_of_validator.rb new file mode 100644 index 000000000..3467e4f1d --- /dev/null +++ b/lib/grape/validations/validators/at_least_one_of_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class AtLeastOneOfValidator < MultipleParamsBase + def validate_params!(params) + return unless keys_in_common(params).empty? + + raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:at_least_one)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/base.rb b/lib/grape/validations/validators/base.rb index 4799f4923..beaba3502 100644 --- a/lib/grape/validations/validators/base.rb +++ b/lib/grape/validations/validators/base.rb @@ -2,87 +2,87 @@ module Grape module Validations - class Base - attr_reader :attrs + module Validators + class Base + attr_reader :attrs - # Creates a new Validator from options specified - # by a +requires+ or +optional+ directive during - # parameter definition. - # @param attrs [Array] names of attributes to which the Validator applies - # @param options [Object] implementation-dependent Validator options - # @param required [Boolean] attribute(s) are required or optional - # @param scope [ParamsScope] parent scope for this Validator - # @param opts [Hash] additional validation options - def initialize(attrs, options, required, scope, **opts) - @attrs = Array(attrs) - @option = options - @required = required - @scope = scope - @fail_fast = opts[:fail_fast] || false - @allow_blank = opts[:allow_blank] || false - end + # Creates a new Validator from options specified + # by a +requires+ or +optional+ directive during + # parameter definition. + # @param attrs [Array] names of attributes to which the Validator applies + # @param options [Object] implementation-dependent Validator options + # @param required [Boolean] attribute(s) are required or optional + # @param scope [ParamsScope] parent scope for this Validator + # @param opts [Hash] additional validation options + def initialize(attrs, options, required, scope, opts) + @attrs = Array(attrs) + @option = options + @required = required + @scope = scope + @fail_fast = opts[:fail_fast] + @allow_blank = opts[:allow_blank] + end - # Validates a given request. - # @note Override #validate! unless you need to access the entire request. - # @param request [Grape::Request] the request currently being handled - # @raise [Grape::Exceptions::Validation] if validation failed - # @return [void] - def validate(request) - return unless @scope.should_validate?(request.params) - validate!(request.params) - end + # Validates a given request. + # @note Override #validate! unless you need to access the entire request. + # @param request [Grape::Request] the request currently being handled + # @raise [Grape::Exceptions::Validation] if validation failed + # @return [void] + def validate(request) + return unless @scope.should_validate?(request.params) + + validate!(request.params) + end + + # Validates a given parameter hash. + # @note Override #validate if you need to access the entire request. + # @param params [Hash] parameters to validate + # @raise [Grape::Exceptions::Validation] if validation failed + # @return [void] + def validate!(params) + attributes = SingleAttributeIterator.new(self, @scope, params) + # we collect errors inside array because + # there may be more than one error per field + array_errors = [] - # Validates a given parameter hash. - # @note Override #validate if you need to access the entire request. - # @param params [Hash] parameters to validate - # @raise [Grape::Exceptions::Validation] if validation failed - # @return [void] - def validate!(params) - attributes = SingleAttributeIterator.new(self, @scope, params) - # we collect errors inside array because - # there may be more than one error per field - array_errors = [] + attributes.each do |val, attr_name, empty_val| + next if !@scope.required? && empty_val + next unless @scope.meets_dependency?(val, params) - attributes.each do |val, attr_name, empty_val| - next if !@scope.required? && empty_val - next unless @scope.meets_dependency?(val, params) - begin - validate_param!(attr_name, val) if @required || val.respond_to?(:key?) && val.key?(attr_name) + validate_param!(attr_name, val) if @required || val.try(:key?, attr_name) rescue Grape::Exceptions::Validation => e array_errors << e end - end - - raise Grape::Exceptions::ValidationArrayErrors, array_errors if array_errors.any? - end - def self.convert_to_short_name(klass) - ret = klass.name.gsub(/::/, '/') - ret.gsub!(/([A-Z]+)([A-Z][a-z])/, '\1_\2') - ret.gsub!(/([a-z\d])([A-Z])/, '\1_\2') - ret.tr!('-', '_') - ret.downcase! - File.basename(ret, '_validator') - end + raise Grape::Exceptions::ValidationArrayErrors.new(array_errors) if array_errors.any? + end - def self.inherited(klass) - return unless klass.name.present? - Validations.register_validator(convert_to_short_name(klass), klass) - end + def self.inherited(klass) + super + Validations.register(klass) + end - def message(default_key = nil) - options = instance_variable_get(:@option) - options_key?(:message) ? options[:message] : default_key - end + def message(default_key = nil) + options = instance_variable_get(:@option) + options_key?(:message) ? options[:message] : default_key + end - def options_key?(key, options = nil) - options = instance_variable_get(:@option) if options.nil? - options.respond_to?(:key?) && options.key?(key) && !options[key].nil? - end + def options_key?(key, options = nil) + options = instance_variable_get(:@option) if options.nil? + options.try(:key?, key) && !options[key].nil? + end - def fail_fast? - @fail_fast + def fail_fast? + @fail_fast + end end end end end + +Grape::Validations::Base = Class.new(Grape::Validations::Validators::Base) do + def self.inherited(*) + Grape.deprecator.warn 'Grape::Validations::Base is deprecated! Use Grape::Validations::Validators::Base instead.' + super + end +end diff --git a/lib/grape/validations/validators/coerce.rb b/lib/grape/validations/validators/coerce.rb deleted file mode 100644 index 5b6f960a6..000000000 --- a/lib/grape/validations/validators/coerce.rb +++ /dev/null @@ -1,88 +0,0 @@ -# frozen_string_literal: true - -module Grape - class API - class Boolean - def self.build(val) - return nil if val != true && val != false - - new - end - end - - class Instance - Boolean = Grape::API::Boolean - end - end - - module Validations - class CoerceValidator < Base - def initialize(*_args) - super - - @converter = if type.is_a?(Grape::Validations::Types::VariantCollectionCoercer) - type - else - Types.build_coercer(type, method: @option[:method]) - end - end - - def validate(request) - super - end - - def validate_param!(attr_name, params) - raise validation_exception(attr_name) unless params.is_a? Hash - - new_value = coerce_value(params[attr_name]) - - raise validation_exception(attr_name) unless valid_type?(new_value) - - # Don't assign a value if it is identical. It fixes a problem with Hashie::Mash - # which looses wrappers for hashes and arrays after reassigning values - # - # h = Hashie::Mash.new(list: [1, 2, 3, 4]) - # => #> - # list = h.list - # h[:list] = list - # h - # => # - return if params[attr_name].class == new_value.class && params[attr_name] == new_value - - params[attr_name] = new_value - end - - private - - # @!attribute [r] converter - # Object that will be used for parameter coercion and type checking. - # - # See {Types.build_coercer} - # - # @return [Object] - attr_reader :converter - - def valid_type?(val) - !val.is_a?(Types::InvalidValue) - end - - def coerce_value(val) - converter.call(val) - # Some custom types might fail, so it should be treated as an invalid value - rescue StandardError - Types::InvalidValue.new - end - - # Type to which the parameter will be coerced. - # - # @return [Class] - def type - @option[:type].is_a?(Hash) ? @option[:type][:value] : @option[:type] - end - - def validation_exception(attr_name) - Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:coerce)) - end - end - end -end diff --git a/lib/grape/validations/validators/coerce_validator.rb b/lib/grape/validations/validators/coerce_validator.rb new file mode 100644 index 000000000..eaf7c4069 --- /dev/null +++ b/lib/grape/validations/validators/coerce_validator.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class CoerceValidator < Base + def initialize(attrs, options, required, scope, opts) + super + + @converter = if type.is_a?(Grape::Validations::Types::VariantCollectionCoercer) + type + else + Types.build_coercer(type, method: @option[:method]) + end + end + + def validate_param!(attr_name, params) + raise validation_exception(attr_name) unless params.is_a? Hash + + new_value = coerce_value(params[attr_name]) + + raise validation_exception(attr_name, new_value.message) unless valid_type?(new_value) + + # Don't assign a value if it is identical. It fixes a problem with Hashie::Mash + # which looses wrappers for hashes and arrays after reassigning values + # + # h = Hashie::Mash.new(list: [1, 2, 3, 4]) + # => #> + # list = h.list + # h[:list] = list + # h + # => # + return if params[attr_name].instance_of?(new_value.class) && params[attr_name] == new_value + + params[attr_name] = new_value + end + + private + + # @!attribute [r] converter + # Object that will be used for parameter coercion and type checking. + # + # See {Types.build_coercer} + # + # @return [Object] + attr_reader :converter + + def valid_type?(val) + !val.is_a?(Types::InvalidValue) + end + + def coerce_value(val) + converter.call(val) + # Some custom types might fail, so it should be treated as an invalid value + rescue StandardError + Types::InvalidValue.new + end + + # Type to which the parameter will be coerced. + # + # @return [Class] + def type + @option[:type].is_a?(Hash) ? @option[:type][:value] : @option[:type] + end + + def validation_exception(attr_name, custom_msg = nil) + Grape::Exceptions::Validation.new( + params: [@scope.full_name(attr_name)], + message: custom_msg || message(:coerce) + ) + end + end + end + end +end diff --git a/lib/grape/validations/validators/contract_scope_validator.rb b/lib/grape/validations/validators/contract_scope_validator.rb new file mode 100644 index 000000000..b8a3365c1 --- /dev/null +++ b/lib/grape/validations/validators/contract_scope_validator.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class ContractScopeValidator < Base + attr_reader :schema + + def initialize(_attrs, _options, _required, _scope, opts) + super + @schema = opts.fetch(: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 + + raise Grape::Exceptions::ValidationArrayErrors.new(build_errors_from_messages(res.errors.messages)) + end + + private + + def build_errors_from_messages(messages) + messages.map do |message| + full_name = message.path.first.to_s + full_name << "[#{message.path[1..].join('][')}]" if message.path.size > 1 + Grape::Exceptions::Validation.new(params: [full_name], message: message.text) + end + end + end + end + end +end diff --git a/lib/grape/validations/validators/default.rb b/lib/grape/validations/validators/default.rb deleted file mode 100644 index 79d6951f3..000000000 --- a/lib/grape/validations/validators/default.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class DefaultValidator < Base - def initialize(attrs, options, required, scope, **opts) - @default = options - super - end - - def validate_param!(attr_name, params) - params[attr_name] = if @default.is_a? Proc - @default.call - elsif @default.frozen? || !duplicatable?(@default) - @default - else - duplicate(@default) - end - end - - def validate!(params) - attrs = SingleAttributeIterator.new(self, @scope, params) - attrs.each do |resource_params, attr_name| - validate_param!(attr_name, resource_params) if resource_params.is_a?(Hash) && resource_params[attr_name].nil? - end - end - - private - - # return true if we might be able to dup this object - def duplicatable?(obj) - !obj.nil? && - obj != true && - obj != false && - !obj.is_a?(Symbol) && - !obj.is_a?(Numeric) - end - - # make a best effort to dup the object - def duplicate(obj) - obj.dup - rescue TypeError - obj - end - end - end -end diff --git a/lib/grape/validations/validators/default_validator.rb b/lib/grape/validations/validators/default_validator.rb new file mode 100644 index 000000000..eba8c7730 --- /dev/null +++ b/lib/grape/validations/validators/default_validator.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class DefaultValidator < Base + def initialize(attrs, options, required, scope, opts = {}) + @default = options + super + end + + def validate_param!(attr_name, params) + params[attr_name] = if @default.is_a? Proc + if @default.parameters.empty? + @default.call + else + @default.call(params) + end + elsif @default.frozen? || !@default.duplicable? + @default + else + @default.dup + end + end + + def validate!(params) + attrs = SingleAttributeIterator.new(self, @scope, params) + attrs.each do |resource_params, attr_name| + next unless @scope.meets_dependency?(resource_params, params) + + validate_param!(attr_name, resource_params) if resource_params.is_a?(Hash) && resource_params[attr_name].nil? + end + end + end + end + end +end diff --git a/lib/grape/validations/validators/exactly_one_of.rb b/lib/grape/validations/validators/exactly_one_of.rb deleted file mode 100644 index b8e4ecb9c..000000000 --- a/lib/grape/validations/validators/exactly_one_of.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require 'grape/validations/validators/multiple_params_base' - -module Grape - module Validations - class ExactlyOneOfValidator < MultipleParamsBase - def validate_params!(params) - keys = keys_in_common(params) - return if keys.length == 1 - raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:exactly_one)) if keys.length.zero? - raise Grape::Exceptions::Validation.new(params: keys, message: message(:mutual_exclusion)) - end - end - end -end diff --git a/lib/grape/validations/validators/exactly_one_of_validator.rb b/lib/grape/validations/validators/exactly_one_of_validator.rb new file mode 100644 index 000000000..aa1c54711 --- /dev/null +++ b/lib/grape/validations/validators/exactly_one_of_validator.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class ExactlyOneOfValidator < MultipleParamsBase + def validate_params!(params) + keys = keys_in_common(params) + return if keys.length == 1 + raise Grape::Exceptions::Validation.new(params: all_keys, message: message(:exactly_one)) if keys.empty? + + raise Grape::Exceptions::Validation.new(params: keys, message: message(:mutual_exclusion)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/except_values.rb b/lib/grape/validations/validators/except_values.rb deleted file mode 100644 index 5ba1e306b..000000000 --- a/lib/grape/validations/validators/except_values.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class ExceptValuesValidator < Base - def initialize(attrs, options, required, scope, **opts) - @except = options.is_a?(Hash) ? options[:value] : options - super - end - - def validate_param!(attr_name, params) - return unless params.respond_to?(:key?) && params.key?(attr_name) - - excepts = @except.is_a?(Proc) ? @except.call : @except - return if excepts.nil? - - param_array = params[attr_name].nil? ? [nil] : Array.wrap(params[attr_name]) - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:except_values)) if param_array.any? { |param| excepts.include?(param) } - end - end - end -end diff --git a/lib/grape/validations/validators/except_values_validator.rb b/lib/grape/validations/validators/except_values_validator.rb new file mode 100644 index 000000000..980226c1d --- /dev/null +++ b/lib/grape/validations/validators/except_values_validator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class ExceptValuesValidator < Base + def initialize(attrs, options, required, scope, opts) + @except = options.is_a?(Hash) ? options[:value] : options + super + end + + def validate_param!(attr_name, params) + return unless params.try(:key?, attr_name) + + excepts = @except.is_a?(Proc) ? @except.call : @except + return if excepts.nil? + + param_array = params[attr_name].nil? ? [nil] : Array.wrap(params[attr_name]) + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:except_values)) if param_array.any? { |param| excepts.include?(param) } + end + end + end + end +end diff --git a/lib/grape/validations/validators/length_validator.rb b/lib/grape/validations/validators/length_validator.rb new file mode 100644 index 000000000..c84b4c096 --- /dev/null +++ b/lib/grape/validations/validators/length_validator.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class LengthValidator < Base + def initialize(attrs, options, required, scope, opts) + @min = options[:min] + @max = options[:max] + @is = options[:is] + + super + + raise ArgumentError, 'min must be an integer greater than or equal to zero' if !@min.nil? && (!@min.is_a?(Integer) || @min.negative?) + raise ArgumentError, 'max must be an integer greater than or equal to zero' if !@max.nil? && (!@max.is_a?(Integer) || @max.negative?) + raise ArgumentError, "min #{@min} cannot be greater than max #{@max}" if !@min.nil? && !@max.nil? && @min > @max + + return if @is.nil? + raise ArgumentError, 'is must be an integer greater than zero' if !@is.is_a?(Integer) || !@is.positive? + raise ArgumentError, 'is cannot be combined with min or max' if !@min.nil? || !@max.nil? + end + + def validate_param!(attr_name, params) + param = params[attr_name] + + return unless param.respond_to?(:length) + + return unless (!@min.nil? && param.length < @min) || (!@max.nil? && param.length > @max) || (!@is.nil? && param.length != @is) + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: build_message) + end + + def build_message + if options_key?(:message) + @option[:message] + elsif @min && @max + format I18n.t(:length, scope: 'grape.errors.messages'), min: @min, max: @max + elsif @min + format I18n.t(:length_min, scope: 'grape.errors.messages'), min: @min + elsif @max + format I18n.t(:length_max, scope: 'grape.errors.messages'), max: @max + else + format I18n.t(:length_is, scope: 'grape.errors.messages'), is: @is + end + end + end + end + end +end diff --git a/lib/grape/validations/validators/multiple_params_base.rb b/lib/grape/validations/validators/multiple_params_base.rb index 013386b59..29df27720 100644 --- a/lib/grape/validations/validators/multiple_params_base.rb +++ b/lib/grape/validations/validators/multiple_params_base.rb @@ -2,31 +2,32 @@ module Grape module Validations - class MultipleParamsBase < Base - def validate!(params) - attributes = MultipleAttributesIterator.new(self, @scope, params) - array_errors = [] + module Validators + class MultipleParamsBase < Base + def validate!(params) + attributes = MultipleAttributesIterator.new(self, @scope, params) + array_errors = [] - attributes.each do |resource_params| - begin + attributes.each do |resource_params| validate_params!(resource_params) rescue Grape::Exceptions::Validation => e array_errors << e end + + raise Grape::Exceptions::ValidationArrayErrors.new(array_errors) if array_errors.any? end - raise Grape::Exceptions::ValidationArrayErrors, array_errors if array_errors.any? - end + private - private + def keys_in_common(resource_params) + return [] unless resource_params.is_a?(Hash) - def keys_in_common(resource_params) - return [] unless resource_params.is_a?(Hash) - all_keys & resource_params.keys.map! { |attr| @scope.full_name(attr) } - end + all_keys & resource_params.keys.map! { |attr| @scope.full_name(attr) } + end - def all_keys - attrs.map { |attr| @scope.full_name(attr) } + def all_keys + attrs.map { |attr| @scope.full_name(attr) } + end end end end diff --git a/lib/grape/validations/validators/mutual_exclusion.rb b/lib/grape/validations/validators/mutual_exclusion.rb deleted file mode 100644 index bcd25bcae..000000000 --- a/lib/grape/validations/validators/mutual_exclusion.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'grape/validations/validators/multiple_params_base' - -module Grape - module Validations - class MutualExclusionValidator < MultipleParamsBase - def validate_params!(params) - keys = keys_in_common(params) - return if keys.length <= 1 - raise Grape::Exceptions::Validation.new(params: keys, message: message(:mutual_exclusion)) - end - end - end -end diff --git a/lib/grape/validations/validators/mutual_exclusion_validator.rb b/lib/grape/validations/validators/mutual_exclusion_validator.rb new file mode 100644 index 000000000..8d19da34c --- /dev/null +++ b/lib/grape/validations/validators/mutual_exclusion_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class MutualExclusionValidator < MultipleParamsBase + def validate_params!(params) + keys = keys_in_common(params) + return if keys.length <= 1 + + raise Grape::Exceptions::Validation.new(params: keys, message: message(:mutual_exclusion)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/presence.rb b/lib/grape/validations/validators/presence.rb deleted file mode 100644 index 92ec570f4..000000000 --- a/lib/grape/validations/validators/presence.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class PresenceValidator < Base - def validate_param!(attr_name, params) - return if params.respond_to?(:key?) && params.key?(attr_name) - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:presence)) - end - end - end -end diff --git a/lib/grape/validations/validators/presence_validator.rb b/lib/grape/validations/validators/presence_validator.rb new file mode 100644 index 000000000..5961aa172 --- /dev/null +++ b/lib/grape/validations/validators/presence_validator.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class PresenceValidator < Base + def validate_param!(attr_name, params) + return if params.try(:key?, attr_name) + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:presence)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/regexp.rb b/lib/grape/validations/validators/regexp.rb deleted file mode 100644 index 23f6a29ad..000000000 --- a/lib/grape/validations/validators/regexp.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class RegexpValidator < Base - def validate_param!(attr_name, params) - return unless params.respond_to?(:key?) && params.key?(attr_name) - return if Array.wrap(params[attr_name]).all? { |param| param.nil? || param.to_s.match?((options_key?(:value) ? @option[:value] : @option)) } - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:regexp)) - end - end - end -end diff --git a/lib/grape/validations/validators/regexp_validator.rb b/lib/grape/validations/validators/regexp_validator.rb new file mode 100644 index 000000000..7b9b2864f --- /dev/null +++ b/lib/grape/validations/validators/regexp_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class RegexpValidator < Base + def validate_param!(attr_name, params) + return unless params.try(:key?, attr_name) + return if Array.wrap(params[attr_name]).all? { |param| param.nil? || param.to_s.scrub.match?((options_key?(:value) ? @option[:value] : @option)) } + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message(:regexp)) + end + end + end + end +end diff --git a/lib/grape/validations/validators/same_as.rb b/lib/grape/validations/validators/same_as.rb deleted file mode 100644 index 087150f16..000000000 --- a/lib/grape/validations/validators/same_as.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class SameAsValidator < Base - def validate_param!(attr_name, params) - confirmation = options_key?(:value) ? @option[:value] : @option - return if params[attr_name] == params[confirmation] - raise Grape::Exceptions::Validation.new( - params: [@scope.full_name(attr_name)], - message: build_message - ) - end - - private - - def build_message - if options_key?(:message) - @option[:message] - else - format I18n.t(:same_as, scope: 'grape.errors.messages'), parameter: @option - end - end - end - end -end diff --git a/lib/grape/validations/validators/same_as_validator.rb b/lib/grape/validations/validators/same_as_validator.rb new file mode 100644 index 000000000..5a65afa60 --- /dev/null +++ b/lib/grape/validations/validators/same_as_validator.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class SameAsValidator < Base + def validate_param!(attr_name, params) + confirmation = options_key?(:value) ? @option[:value] : @option + return if params[attr_name] == params[confirmation] + + raise Grape::Exceptions::Validation.new( + params: [@scope.full_name(attr_name)], + message: build_message + ) + end + + private + + def build_message + if options_key?(:message) + @option[:message] + else + format I18n.t(:same_as, scope: 'grape.errors.messages'), parameter: @option + end + end + end + end + end +end diff --git a/lib/grape/validations/validators/values.rb b/lib/grape/validations/validators/values.rb deleted file mode 100644 index f3d676d0b..000000000 --- a/lib/grape/validations/validators/values.rb +++ /dev/null @@ -1,83 +0,0 @@ -# frozen_string_literal: true - -module Grape - module Validations - class ValuesValidator < Base - def initialize(attrs, options, required, scope, **opts) - if options.is_a?(Hash) - @excepts = options[:except] - @values = options[:value] - @proc = options[:proc] - - warn '[DEPRECATION] The values validator except option is deprecated. ' \ - 'Use the except validator instead.' if @excepts - - raise ArgumentError, 'proc must be a Proc' if @proc && !@proc.is_a?(Proc) - warn '[DEPRECATION] The values validator proc option is deprecated. ' \ - 'The lambda expression can now be assigned directly to values.' if @proc - else - @excepts = nil - @values = nil - @proc = nil - @values = options - end - super - end - - def validate_param!(attr_name, params) - return unless params.is_a?(Hash) - - val = params[attr_name] - - return if val.nil? && !required_for_root_scope? - - # don't forget that +false.blank?+ is true - return if val != false && val.blank? && @allow_blank - - param_array = val.nil? ? [nil] : Array.wrap(val) - - raise validation_exception(attr_name, except_message) \ - unless check_excepts(param_array) - - raise validation_exception(attr_name, message(:values)) \ - unless check_values(param_array, attr_name) - - raise validation_exception(attr_name, message(:values)) \ - if @proc && !param_array.all? { |param| @proc.call(param) } - end - - private - - def check_values(param_array, attr_name) - values = @values.is_a?(Proc) && @values.arity.zero? ? @values.call : @values - return true if values.nil? - begin - return param_array.all? { |param| values.call(param) } if values.is_a? Proc - rescue StandardError => e - warn "Error '#{e}' raised while validating attribute '#{attr_name}'" - return false - end - param_array.all? { |param| values.include?(param) } - end - - def check_excepts(param_array) - excepts = @excepts.is_a?(Proc) ? @excepts.call : @excepts - return true if excepts.nil? - param_array.none? { |param| excepts.include?(param) } - end - - def except_message - options = instance_variable_get(:@option) - options_key?(:except_message) ? options[:except_message] : message(:except_values) - end - - def required_for_root_scope? - @required && @scope.root? - end - - def validation_exception(attr_name, message) - Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message) - end - end - end -end diff --git a/lib/grape/validations/validators/values_validator.rb b/lib/grape/validations/validators/values_validator.rb new file mode 100644 index 000000000..11f314a57 --- /dev/null +++ b/lib/grape/validations/validators/values_validator.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Grape + module Validations + module Validators + class ValuesValidator < Base + def initialize(attrs, options, required, scope, opts) + @values = options.is_a?(Hash) ? options[:value] : options + super + end + + def validate_param!(attr_name, params) + return unless params.is_a?(Hash) + + val = params[attr_name] + + return if val.nil? && !required_for_root_scope? + + val = val.scrub if val.respond_to?(:scrub) + + # don't forget that +false.blank?+ is true + return if val != false && val.blank? && @allow_blank + + return if check_values?(val, attr_name) + + raise Grape::Exceptions::Validation.new( + params: [@scope.full_name(attr_name)], + message: message(:values) + ) + end + + private + + def check_values?(val, attr_name) + values = @values.is_a?(Proc) && @values.arity.zero? ? @values.call : @values + return true if values.nil? + + param_array = val.nil? ? [nil] : Array.wrap(val) + return param_array.all? { |param| values.include?(param) } unless values.is_a?(Proc) + + begin + param_array.all? { |param| values.call(param) } + rescue StandardError => e + warn "Error '#{e}' raised while validating attribute '#{attr_name}'" + false + end + end + + def required_for_root_scope? + return false unless @required + + scope = @scope + scope = scope.parent while scope.lateral? + + scope.root? + end + end + end + end +end diff --git a/lib/grape/version.rb b/lib/grape/version.rb index 615c251ab..72bbbe335 100644 --- a/lib/grape/version.rb +++ b/lib/grape/version.rb @@ -2,5 +2,5 @@ module Grape # The current version of Grape. - VERSION = '1.4.1' + VERSION = '2.4.0' end diff --git a/lib/grape/util/xml.rb b/lib/grape/xml.rb similarity index 80% rename from lib/grape/util/xml.rb rename to lib/grape/xml.rb index d948f8012..85287814a 100644 --- a/lib/grape/util/xml.rb +++ b/lib/grape/xml.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Grape - if Object.const_defined? :MultiXml + if defined?(::MultiXml) Xml = ::MultiXml else Xml = ::ActiveSupport::XmlMini diff --git a/spec/config/spec_test_prof.rb b/spec/config/spec_test_prof.rb new file mode 100644 index 000000000..e5259e110 --- /dev/null +++ b/spec/config/spec_test_prof.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_prof/recipes/rspec/let_it_be' + +TestProf::BeforeAll.adapter = Class.new do + def begin_transaction; end + + def rollback_transaction; end +end.new diff --git a/spec/grape/api/custom_validations_spec.rb b/spec/grape/api/custom_validations_spec.rb index f69ec5199..6ed10527a 100644 --- a/spec/grape/api/custom_validations_spec.rb +++ b/spec/grape/api/custom_validations_spec.rb @@ -1,20 +1,17 @@ # frozen_string_literal: true -require 'spec_helper' +require 'shared/deprecated_class_examples' describe Grape::Validations do - context 'using a custom length validator' do - before do - module CustomValidationsSpec - class DefaultLength < Grape::Validations::Base - def validate_param!(attr_name, params) - @option = params[:max].to_i if params.key?(:max) - return if 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 - end - end + describe 'Grape::Validations::Base' do + let(:deprecated_class) do + Class.new(Grape::Validations::Base) end + + it_behaves_like 'deprecated class' + end + + describe 'using a custom length validator' do subject do Class.new(Grape::API) do params do @@ -26,8 +23,25 @@ def validate_param!(attr_name, params) end end - def app - subject + let(:default_length_validator) do + Class.new(Grape::Validations::Validators::Base) do + def validate_param!(attr_name, params) + @option = params[:max].to_i if params.key?(:max) + return if 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 + end + end + let(:app) { subject } + + before do + stub_const('DefaultLengthValidator', default_length_validator) + described_class.register(DefaultLengthValidator) + end + + after do + described_class.deregister(:default_length) end it 'under 140 characters' do @@ -35,11 +49,13 @@ def app expect(last_response.status).to eq 200 expect(last_response.body).to eq 'bacon' end + it 'over 140 characters' do get '/', text: 'a' * 141 expect(last_response.status).to eq 400 expect(last_response.body).to eq 'text must be at the most 140 characters long' end + it 'specified in the query string' do get '/', text: 'a' * 141, max: 141 expect(last_response.status).to eq 200 @@ -47,16 +63,7 @@ def app end end - context 'using a custom body-only validator' do - before do - module CustomValidationsSpec - class InBody < Grape::Validations::PresenceValidator - def validate(request) - validate!(request.env['api.request.body']) - end - end - end - end + describe 'using a custom body-only validator' do subject do Class.new(Grape::API) do params do @@ -68,8 +75,22 @@ def validate(request) end end - def app - subject + let(:in_body_validator) do + Class.new(Grape::Validations::Validators::PresenceValidator) do + def validate(request) + validate!(request.env[Grape::Env::API_REQUEST_BODY]) + end + end + end + let(:app) { subject } + + before do + stub_const('InBodyValidator', in_body_validator) + described_class.register(InBodyValidator) + end + + after do + described_class.deregister(:in_body) end it 'allows field in body' do @@ -77,6 +98,7 @@ def app expect(last_response.status).to eq 200 expect(last_response.body).to eq 'bacon' end + it 'ignores field in query' do get '/', nil, text: 'abc' expect(last_response.status).to eq 400 @@ -84,16 +106,7 @@ def app end end - context 'using a custom validator with message_key' do - before do - module CustomValidationsSpec - class WithMessageKey < Grape::Validations::PresenceValidator - def validate_param!(attr_name, _params) - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: :presence) - end - end - end - end + describe 'using a custom validator with message_key' do subject do Class.new(Grape::API) do params do @@ -105,8 +118,22 @@ def validate_param!(attr_name, _params) end end - def app - subject + let(:message_key_validator) do + Class.new(Grape::Validations::Validators::PresenceValidator) do + def validate_param!(attr_name, _params) + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: :presence) + end + end + end + let(:app) { subject } + + before do + stub_const('WithMessageKeyValidator', message_key_validator) + described_class.register(WithMessageKeyValidator) + end + + after do + described_class.deregister(:with_message_key) end it 'fails with message' do @@ -116,23 +143,7 @@ def app end end - context 'using a custom request/param validator' do - before do - module CustomValidationsSpec - class Admin < Grape::Validations::Base - def validate(request) - # return if the param we are checking was not in request - # @attrs is a list containing the attribute we are currently validating - return unless request.params.key? @attrs.first - # check if admin flag is set to true - return unless @option - # 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' - end - end - end - end + describe 'using a custom request/param validator' do subject do Class.new(Grape::API) do params do @@ -146,8 +157,36 @@ def validate(request) end end - def app - subject + let(:admin_validator) do + Class.new(Grape::Validations::Validators::Base) do + def validate(request) + # return if the param we are checking was not in request + # @attrs is a list containing the attribute we are currently validating + return unless request.params.key? @attrs.first + # check if admin flag is set to true + return unless @option + + # 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[access_header] == 'admin' + end + + def access_header + 'x-access-token' + end + end + end + + let(:app) { subject } + let(:x_access_token_header) { 'x-access-token' } + + before do + stub_const('AdminValidator', admin_validator) + described_class.register(AdminValidator) + end + + after do + described_class.deregister(:admin) end it 'fail when non-admin user sets an admin field' do @@ -169,17 +208,57 @@ def app end it 'does not fail when we send admin fields and we are admin' do - header 'X-Access-Token', 'admin' + header x_access_token_header, 'admin' get '/', admin_field: 'tester', non_admin_field: 'toaster', admin_false_field: 'test' expect(last_response.status).to eq 200 expect(last_response.body).to eq 'bacon' end it 'fails when we send admin fields and we are not admin' do - header 'X-Access-Token', 'user' + header x_access_token_header, 'user' get '/', admin_field: 'tester', non_admin_field: 'toaster', admin_false_field: 'test' expect(last_response.status).to eq 400 expect(last_response.body).to include 'Can not set Admin only field.' end end + + describe 'using a custom validator with instance variable' do + let(:validator_type) do + Class.new(Grape::Validations::Validators::Base) do + def validate_param!(_attr_name, _params) + if instance_variable_defined?(:@instance_variable) && @instance_variable + raise Grape::Exceptions::Validation.new(params: ['params'], + message: 'This should never happen') + end + @instance_variable = true + end + end + end + let(:app) do + Class.new(Grape::API) do + params do + optional :param_to_validate, instance_validator: true + optional :another_param_to_validate, instance_validator: true + end + get do + 'noop' + end + end + end + + before do + stub_const('InstanceValidatorValidator', validator_type) + described_class.register(InstanceValidatorValidator) + end + + after do + described_class.deregister(:instance_validator) + end + + it 'passes validation every time' do + expect(validator_type).to receive(:new).twice.and_call_original + get '/', param_to_validate: 'value', another_param_to_validate: 'value' + expect(last_response.status).to eq 200 + end + end end diff --git a/spec/grape/api/deeply_included_options_spec.rb b/spec/grape/api/deeply_included_options_spec.rb index 71cc1385b..940e11560 100644 --- a/spec/grape/api/deeply_included_options_spec.rb +++ b/spec/grape/api/deeply_included_options_spec.rb @@ -1,23 +1,17 @@ # frozen_string_literal: true -require 'spec_helper' - -module DeeplyIncludedOptionsSpec - module Defaults - extend ActiveSupport::Concern - included do - format :json +describe Grape::API do + let(:app) do + main_api = api + Class.new(Grape::API) do + mount main_api end end - module Admin - module Defaults - extend ActiveSupport::Concern - include DeeplyIncludedOptionsSpec::Defaults - end - - class Users < Grape::API - include DeeplyIncludedOptionsSpec::Admin::Defaults + let(:api) do + deeply_included_options = options + Class.new(Grape::API) do + include deeply_included_options resource :users do get do @@ -27,32 +21,37 @@ class Users < Grape::API end end - class Main < Grape::API - mount DeeplyIncludedOptionsSpec::Admin::Users + let(:options) do + deep_included_options_default = default + Module.new do + extend ActiveSupport::Concern + include deep_included_options_default + end end -end - -describe Grape::API do - subject { DeeplyIncludedOptionsSpec::Main } - def app - subject + let(:default) do + Module.new do + extend ActiveSupport::Concern + included do + format :json + end + end end it 'works for unspecified format' do get '/users' - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 expect(last_response.content_type).to eql 'application/json' end it 'works for specified format' do get '/users.json' - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 expect(last_response.content_type).to eql 'application/json' end it "doesn't work for format different than specified" do get '/users.txt' - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 end end diff --git a/spec/grape/api/defines_boolean_in_params_spec.rb b/spec/grape/api/defines_boolean_in_params_spec.rb index 8a0302a23..e885eff0d 100644 --- a/spec/grape/api/defines_boolean_in_params_spec.rb +++ b/spec/grape/api/defines_boolean_in_params_spec.rb @@ -1,13 +1,11 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Instance do describe 'boolean constant' do - module DefinesBooleanInstanceSpec - class API < Grape::API + let(:app) do + Class.new(Grape::API) do params do - requires :message, type: Boolean + requires :message, type: Grape::API::Boolean end post :echo do { class: params[:message].class.name, value: params[:message] } @@ -15,10 +13,6 @@ class API < Grape::API end end - def app - DefinesBooleanInstanceSpec::API - end - let(:expected_body) do { class: 'TrueClass', value: true }.to_s end @@ -30,9 +24,10 @@ def app end context 'Params endpoint type' do - subject { DefinesBooleanInstanceSpec::API.new.router.map['POST'].first.options[:params]['message'][:type] } + subject { app.new.router.map[Rack::POST].first.options[:params]['message'][:type] } + it 'params type is a boolean' do - is_expected.to eq 'Grape::API::Boolean' + expect(subject).to eq 'Grape::API::Boolean' end end end diff --git a/spec/grape/api/documentation_spec.rb b/spec/grape/api/documentation_spec.rb new file mode 100644 index 000000000..27ec7cc53 --- /dev/null +++ b/spec/grape/api/documentation_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +describe Grape::API do + subject { Class.new(described_class) } + + let(:app) { subject } + + context 'an endpoint with documentation' do + it 'documents parameters' do + subject.params do + requires 'price', type: Float, desc: 'Sales price' + end + subject.get '/' + + expect(subject.routes.first.params['price']).to eq(required: true, + type: 'Float', + desc: 'Sales price') + end + + it 'allows documentation with a hash' do + documentation = { example: 'Joe' } + + subject.params do + requires 'first_name', documentation: documentation + end + subject.get '/' + + expect(subject.routes.first.params['first_name'][:documentation]).to eq(documentation) + end + end + + context 'an endpoint without documentation' do + before do + subject.do_not_document! + + subject.params do + requires :city, type: String, desc: 'Should be ignored' + optional :postal_code, type: Integer + end + subject.post '/' do + declared(params).to_json + end + end + + it 'does not document parameters for the endpoint' do + expect(subject.routes.first.params).to eq({}) + end + + it 'still declares params internally' do + data = { city: 'Berlin', postal_code: 10_115 } + + post '/', data + + expect(last_response.body).to eq(data.to_json) + end + end +end diff --git a/spec/grape/api/inherited_helpers_spec.rb b/spec/grape/api/inherited_helpers_spec.rb index be2cb9179..49597bc5b 100644 --- a/spec/grape/api/inherited_helpers_spec.rb +++ b/spec/grape/api/inherited_helpers_spec.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Helpers do let(:user) { 'Miguel Caneo' } let(:id) { '42' } - - module InheritedHelpersSpec - class SuperClass < Grape::API + let(:api_super_class) do + Class.new(Grape::API) do helpers do params(:superclass_params) { requires :id, type: String } @@ -16,8 +13,9 @@ def current_user end end end - - class OverriddenSubClass < SuperClass + end + let(:api_overridden_sub_class) do + Class.new(api_super_class) do params { use :superclass_params } helpers do @@ -30,16 +28,18 @@ def current_user "#{current_user}: #{params['id']}" end end - - class SubClass < SuperClass + end + let(:api_sub_class) do + Class.new(api_super_class) do params { use :superclass_params } get 'resource' do "#{current_user}: #{params['id']}" end end - - class Example < SubClass + end + let(:api_example) do + Class.new(api_sub_class) do params { use :superclass_params } get 'resource' do @@ -49,7 +49,7 @@ class Example < SubClass end context 'non overriding subclass' do - subject { InheritedHelpersSpec::SubClass } + subject { api_sub_class } def app subject @@ -71,10 +71,8 @@ def app end context 'overriding subclass' do - subject { InheritedHelpersSpec::OverriddenSubClass } - def app - subject + api_overridden_sub_class end context 'given expected params' do @@ -93,10 +91,8 @@ def app end context 'example subclass' do - subject { InheritedHelpersSpec::Example } - def app - subject + api_example end context 'given expected params' do diff --git a/spec/grape/api/instance_spec.rb b/spec/grape/api/instance_spec.rb index f0a278a7b..56fd1b0a3 100644 --- a/spec/grape/api/instance_spec.rb +++ b/spec/grape/api/instance_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' require 'shared/versioning_examples' describe Grape::API::Instance do diff --git a/spec/grape/api/invalid_format_spec.rb b/spec/grape/api/invalid_format_spec.rb index e3e78f7be..0e6399d32 100644 --- a/spec/grape/api/invalid_format_spec.rb +++ b/spec/grape/api/invalid_format_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } @@ -29,17 +27,19 @@ def app it 'no format' do get '/foo' expect(last_response.status).to eq 200 - expect(last_response.body).to eq(::Grape::Json.dump(id: 'foo', format: nil)) + expect(last_response.body).to eq(Grape::Json.dump(id: 'foo', format: nil)) end + it 'json format' do get '/foo.json' expect(last_response.status).to eq 200 - expect(last_response.body).to eq(::Grape::Json.dump(id: 'foo', format: 'json')) + expect(last_response.body).to eq(Grape::Json.dump(id: 'foo', format: 'json')) end + it 'invalid format' do get '/foo.invalid' expect(last_response.status).to eq 200 - expect(last_response.body).to eq(::Grape::Json.dump(id: 'foo', format: 'invalid')) + expect(last_response.body).to eq(Grape::Json.dump(id: 'foo', format: 'invalid')) end end end diff --git a/spec/grape/api/mount_and_helpers_order_spec.rb b/spec/grape/api/mount_and_helpers_order_spec.rb new file mode 100644 index 000000000..3485a820e --- /dev/null +++ b/spec/grape/api/mount_and_helpers_order_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +describe Grape::API do + describe 'rescue_from' do + context 'when the API is mounted AFTER defining the class rescue_from handler' do + let(:api_rescue_from) do + Class.new(Grape::API) do + rescue_from :all do + error!({ type: 'all' }, 404) + end + + get do + { count: 1 / 0 } + end + end + end + + let(:main_rescue_from_after) do + context = self + + Class.new(Grape::API) do + rescue_from ZeroDivisionError do + error!({ type: 'zero' }, 500) + end + + mount context.api_rescue_from + end + end + + def app + main_rescue_from_after + end + + it 'is rescued by the rescue_from ZeroDivisionError handler from Main class' do + get '/' + + expect(last_response.status).to eq(500) + expect(last_response.body).to eq({ type: 'zero' }.to_json) + end + end + + context 'when the API is mounted BEFORE defining the class rescue_from handler' do + let(:api_rescue_from) do + Class.new(Grape::API) do + rescue_from :all do + error!({ type: 'all' }, 404) + end + + get do + { count: 1 / 0 } + end + end + end + let(:main_rescue_from_before) do + context = self + + Class.new(Grape::API) do + mount context.api_rescue_from + + rescue_from ZeroDivisionError do + error!({ type: 'zero' }, 500) + end + end + end + + def app + main_rescue_from_before + end + + it 'is rescued by the rescue_from ZeroDivisionError handler from Main class' do + get '/' + + expect(last_response.status).to eq(500) + expect(last_response.body).to eq({ type: 'zero' }.to_json) + end + end + end + + describe 'before' do + context 'when the API is mounted AFTER defining the before helper' do + let(:api_before_handler) do + Class.new(Grape::API) do + get do + { count: @count }.to_json + end + end + end + let(:main_before_handler_after) do + context = self + + Class.new(Grape::API) do + before do + @count = 1 + end + + mount context.api_before_handler + end + end + + def app + main_before_handler_after + end + + it 'is able to access the variables defined in the before helper' do + get '/' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq({ count: 1 }.to_json) + end + end + + context 'when the API is mounted BEFORE defining the before helper' do + let(:api_before_handler) do + Class.new(Grape::API) do + get do + { count: @count }.to_json + end + end + end + let(:main_before_handler_before) do + context = self + + Class.new(Grape::API) do + mount context.api_before_handler + + before do + @count = 1 + end + end + end + + def app + main_before_handler_before + end + + it 'is able to access the variables defined in the before helper' do + get '/' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq({ count: 1 }.to_json) + end + end + end + + describe 'after' do + context 'when the API is mounted AFTER defining the after handler' do + let(:api_after_handler) do + Class.new(Grape::API) do + get do + { count: 1 }.to_json + end + end + end + let(:main_after_handler_after) do + context = self + + Class.new(Grape::API) do + after do + error!({ type: 'after' }, 500) + end + + mount context.api_after_handler + end + end + + def app + main_after_handler_after + end + + it 'is able to access the variables defined in the after helper' do + get '/' + + expect(last_response.status).to eq(500) + expect(last_response.body).to eq({ type: 'after' }.to_json) + end + end + + context 'when the API is mounted BEFORE defining the after helper' do + let(:api_after_handler) do + Class.new(Grape::API) do + get do + { count: 1 }.to_json + end + end + end + let(:main_after_handler_before) do + context = self + + Class.new(Grape::API) do + mount context.api_after_handler + + after do + error!({ type: 'after' }, 500) + end + end + end + + def app + main_after_handler_before + end + + it 'is able to access the variables defined in the after helper' do + get '/' + + expect(last_response.status).to eq(500) + expect(last_response.body).to eq({ type: 'after' }.to_json) + end + end + end +end diff --git a/spec/grape/api/mount_and_rescue_from_spec.rb b/spec/grape/api/mount_and_rescue_from_spec.rb new file mode 100644 index 000000000..1289a7134 --- /dev/null +++ b/spec/grape/api/mount_and_rescue_from_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +describe Grape::API do + context 'when multiple classes defines the same rescue_from' do + let(:an_api) do + Class.new(Grape::API) do + rescue_from ZeroDivisionError do + error!({ type: 'an-api-zero' }, 404) + end + + get '/an-api' do + { count: 1 / 0 } + end + end + end + let(:another_api) do + Class.new(Grape::API) do + rescue_from ZeroDivisionError do + error!({ type: 'another-api-zero' }, 322) + end + + get '/another-api' do + { count: 1 / 0 } + end + end + end + let(:other_main) do + context = self + + Class.new(Grape::API) do + mount context.an_api + mount context.another_api + end + end + + def app + other_main + end + + it 'is rescued by the rescue_from ZeroDivisionError handler defined inside each of the classes' do + get '/an-api' + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq({ type: 'an-api-zero' }.to_json) + + get '/another-api' + + expect(last_response.status).to eq(322) + expect(last_response.body).to eq({ type: 'another-api-zero' }.to_json) + end + + context 'when some class does not define a rescue_from but it was defined in a previous mounted endpoint' do + let(:an_api_without_defined_rescue_from) do + Class.new(Grape::API) do + get '/another-api-without-defined-rescue-from' do + { count: 1 / 0 } + end + end + end + let(:other_main_with_not_defined_rescue_from) do + context = self + + Class.new(Grape::API) do + mount context.an_api + mount context.another_api + mount context.an_api_without_defined_rescue_from + end + end + + def app + other_main_with_not_defined_rescue_from + end + + it 'is not rescued by any of the previous defined rescue_from ZeroDivisionError handlers' do + get '/an-api' + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq({ type: 'an-api-zero' }.to_json) + + get '/another-api' + + expect(last_response.status).to eq(322) + expect(last_response.body).to eq({ type: 'another-api-zero' }.to_json) + + expect do + get '/another-api-without-defined-rescue-from' + end.to raise_error(ZeroDivisionError) + end + end + end +end diff --git a/spec/grape/api/namespace_parameters_in_route_spec.rb b/spec/grape/api/namespace_parameters_in_route_spec.rb index e8496a4a5..f2562d5da 100644 --- a/spec/grape/api/namespace_parameters_in_route_spec.rb +++ b/spec/grape/api/namespace_parameters_in_route_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } diff --git a/spec/grape/api/nested_helpers_spec.rb b/spec/grape/api/nested_helpers_spec.rb index 2f9c61aba..77975f434 100644 --- a/spec/grape/api/nested_helpers_spec.rb +++ b/spec/grape/api/nested_helpers_spec.rb @@ -1,19 +1,20 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Helpers do - module NestedHelpersSpec - module HelperMethods + let(:helper_methods) do + Module.new do extend Grape::API::Helpers def current_user @current_user ||= params[:current_user] end end + end + let(:nested) do + context = self - class Nested < Grape::API + Class.new(Grape::API) do resource :level1 do - helpers HelperMethods + helpers context.helper_methods get do current_user @@ -26,18 +27,17 @@ class Nested < Grape::API end end end - - class Main < Grape::API - mount Nested - end end + let(:main) do + context = self - subject do - NestedHelpersSpec::Main + Class.new(Grape::API) do + mount context.nested + end end def app - subject + main end it 'can access helpers from a mounted resource' do diff --git a/spec/grape/api/optional_parameters_in_route_spec.rb b/spec/grape/api/optional_parameters_in_route_spec.rb index 6bc6bc47d..bb9a89440 100644 --- a/spec/grape/api/optional_parameters_in_route_spec.rb +++ b/spec/grape/api/optional_parameters_in_route_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } diff --git a/spec/grape/api/parameters_modification_spec.rb b/spec/grape/api/parameters_modification_spec.rb index 2d1ea1e20..2d64f9fd3 100644 --- a/spec/grape/api/parameters_modification_spec.rb +++ b/spec/grape/api/parameters_modification_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } diff --git a/spec/grape/api/patch_method_helpers_spec.rb b/spec/grape/api/patch_method_helpers_spec.rb index a369a284a..ad0a162ba 100644 --- a/spec/grape/api/patch_method_helpers_spec.rb +++ b/spec/grape/api/patch_method_helpers_spec.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Helpers do - module PatchHelpersSpec - class PatchPublic < Grape::API + let(:patch_public) do + Class.new(Grape::API) do format :json version 'public-v1', using: :header, vendor: 'grape' @@ -12,16 +10,20 @@ class PatchPublic < Grape::API { ok: 'public' } end end - - module AuthMethods + end + let(:auth_methods) do + Module.new do def authenticate!; end end + end + let(:patch_private) do + context = self - class PatchPrivate < Grape::API + Class.new(Grape::API) do format :json version 'private-v1', using: :header, vendor: 'grape' - helpers AuthMethods + helpers context.auth_methods before do authenticate! @@ -31,15 +33,18 @@ class PatchPrivate < Grape::API { ok: 'private' } end end + end + let(:main) do + context = self - class Main < Grape::API - mount PatchPublic - mount PatchPrivate + Class.new(Grape::API) do + mount context.patch_public + mount context.patch_private end end def app - PatchHelpersSpec::Main + main end context 'patch' do diff --git a/spec/grape/api/recognize_path_spec.rb b/spec/grape/api/recognize_path_spec.rb index 0d821f57b..04b0b48b8 100644 --- a/spec/grape/api/recognize_path_spec.rb +++ b/spec/grape/api/recognize_path_spec.rb @@ -1,10 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API do describe '.recognize_path' do - subject { Class.new(Grape::API) } + subject { Class.new(described_class) } it 'fetches endpoint by given path' do subject.get('/foo/:id') {} @@ -19,5 +17,60 @@ subject.get {} expect(subject.recognize_path('/bar/1234')).to be_nil end + + context 'when parametrized route with type specified together with a static route' do + subject do + Class.new(described_class) do + resource :books do + route_param :id, type: Integer do + get do + end + + resource :loans do + route_param :loan_id, type: Integer do + get do + end + end + + resource :print do + post do + end + end + end + end + + resource :share do + post do + end + end + end + end + end + + it 'recognizes the static route when the parameter does not match with the specified type' do + actual = subject.recognize_path('/books/share').routes[0].origin + expect(actual).to eq('/books/share') + end + + it 'does not recognize any endpoint when there is not other endpoint that matches with the requested path' do + actual = subject.recognize_path('/books/other') + expect(actual).to be_nil + end + + it 'recognizes the parametrized route when the parameter matches with the specified type' do + actual = subject.recognize_path('/books/1').routes[0].origin + expect(actual).to eq('/books/:id') + end + + it 'recognizes the static nested route when the parameter does not match with the specified type' do + actual = subject.recognize_path('/books/1/loans/print').routes[0].origin + expect(actual).to eq('/books/:id/loans/print') + end + + it 'recognizes the nested parametrized route when the parameter matches with the specified type' do + actual = subject.recognize_path('/books/1/loans/33').routes[0].origin + expect(actual).to eq('/books/:id/loans/:loan_id') + end + end end end diff --git a/spec/grape/api/required_parameters_in_route_spec.rb b/spec/grape/api/required_parameters_in_route_spec.rb index 63544892a..3408a16fa 100644 --- a/spec/grape/api/required_parameters_in_route_spec.rb +++ b/spec/grape/api/required_parameters_in_route_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } diff --git a/spec/grape/api/required_parameters_with_invalid_method_spec.rb b/spec/grape/api/required_parameters_with_invalid_method_spec.rb index 0829492cf..10b5a904f 100644 --- a/spec/grape/api/required_parameters_with_invalid_method_spec.rb +++ b/spec/grape/api/required_parameters_with_invalid_method_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } diff --git a/spec/grape/api/routes_with_requirements_spec.rb b/spec/grape/api/routes_with_requirements_spec.rb index 83c5b85ec..f283aa2af 100644 --- a/spec/grape/api/routes_with_requirements_spec.rb +++ b/spec/grape/api/routes_with_requirements_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } @@ -11,7 +9,7 @@ def app context 'get' do it 'routes to a namespace param with dots' do - subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^\/]+} } do + subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^/]+} } do get '/' do params[:ns_with_dots] end @@ -23,8 +21,8 @@ def app end it 'routes to a path with multiple params with dots' do - subject.get ':id_with_dots/:another_id_with_dots', requirements: { id_with_dots: %r{[^\/]+}, - another_id_with_dots: %r{[^\/]+} } do + subject.get ':id_with_dots/:another_id_with_dots', requirements: { id_with_dots: %r{[^/]+}, + another_id_with_dots: %r{[^/]+} } do "#{params[:id_with_dots]}/#{params[:another_id_with_dots]}" end @@ -34,9 +32,9 @@ def app end it 'routes to namespace and path params with dots, with overridden requirements' do - subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^\/]+} } do - get ':another_id_with_dots', requirements: { ns_with_dots: %r{[^\/]+}, - another_id_with_dots: %r{[^\/]+} } do + subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^/]+} } do + get ':another_id_with_dots', requirements: { ns_with_dots: %r{[^/]+}, + another_id_with_dots: %r{[^/]+} } do "#{params[:ns_with_dots]}/#{params[:another_id_with_dots]}" end end @@ -47,8 +45,8 @@ def app end it 'routes to namespace and path params with dots, with merged requirements' do - subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^\/]+} } do - get ':another_id_with_dots', requirements: { another_id_with_dots: %r{[^\/]+} } do + subject.namespace ':ns_with_dots', requirements: { ns_with_dots: %r{[^/]+} } do + get ':another_id_with_dots', requirements: { another_id_with_dots: %r{[^/]+} } do "#{params[:ns_with_dots]}/#{params[:another_id_with_dots]}" end end diff --git a/spec/grape/api/shared_helpers_exactly_one_of_spec.rb b/spec/grape/api/shared_helpers_exactly_one_of_spec.rb index 049fd8706..070265713 100644 --- a/spec/grape/api/shared_helpers_exactly_one_of_spec.rb +++ b/spec/grape/api/shared_helpers_exactly_one_of_spec.rb @@ -1,21 +1,17 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Helpers do - subject do - shared_params = Module.new do - extend Grape::API::Helpers + let(:app) do + Class.new(Grape::API) do + helpers Module.new do + extend Grape::API::Helpers - params :drink do - optional :beer - optional :wine - exactly_one_of :beer, :wine + params :drink do + optional :beer + optional :wine + exactly_one_of :beer, :wine + end end - end - - Class.new(Grape::API) do - helpers shared_params format :json params do @@ -35,10 +31,6 @@ end end - def app - subject - end - it 'defines parameters' do get '/', orderType: 'food', pizza: 'mista' expect(last_response.status).to eq 200 diff --git a/spec/grape/api/shared_helpers_spec.rb b/spec/grape/api/shared_helpers_spec.rb index 68626944d..842417138 100644 --- a/spec/grape/api/shared_helpers_spec.rb +++ b/spec/grape/api/shared_helpers_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API::Helpers do subject do shared_params = Module.new do diff --git a/spec/grape/api_remount_spec.rb b/spec/grape/api_remount_spec.rb index 2f7501765..ed5326b82 100644 --- a/spec/grape/api_remount_spec.rb +++ b/spec/grape/api_remount_spec.rb @@ -1,15 +1,13 @@ # frozen_string_literal: true -require 'spec_helper' require 'shared/versioning_examples' describe Grape::API do - subject(:a_remounted_api) { Class.new(Grape::API) } - let(:root_api) { Class.new(Grape::API) } + subject(:a_remounted_api) { Class.new(described_class) } - def app - root_api - end + let(:root_api) { Class.new(described_class) } + + let(:app) { root_api } describe 'remounting an API' do context 'with a defined route' do @@ -68,7 +66,7 @@ def app describe 'with dynamic configuration' do context 'when mounting an endpoint conditional on a configuration' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do get 'always' do 'success' end @@ -95,13 +93,13 @@ def app expect(last_response.body).to eq 'success' get '/without_conditional/sometimes' - expect(last_response.status).to eq 404 + expect(last_response).to be_not_found end end context 'when using an expression derived from a configuration' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do get(mounted { "api_name_#{configuration[:api_name]}" }) do 'success' end @@ -126,7 +124,7 @@ def app context 'when the expression lives in a namespace' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do namespace :base do get(mounted { "api_name_#{configuration[:api_name]}" }) do 'success' @@ -147,9 +145,45 @@ def app end end + context 'when the params are configured via a configuration' do + subject(:a_remounted_api) do + Class.new(described_class) do + params do + requires configuration[:required_attr_name], type: String + end + + get(mounted { configuration[:endpoint] }) do + status 200 + end + end + end + + context 'when the configured param is my_attr' do + it 'requires the configured params' do + root_api.mount a_remounted_api, with: { + required_attr_name: 'my_attr', + endpoint: 'test' + } + get 'test?another_attr=1' + expect(last_response).to be_bad_request + get 'test?my_attr=1' + expect(last_response).to be_successful + + root_api.mount a_remounted_api, with: { + required_attr_name: 'another_attr', + endpoint: 'test_b' + } + get 'test_b?another_attr=1' + expect(last_response).to be_successful + get 'test_b?my_attr=1' + expect(last_response).to be_bad_request + end + end + end + context 'when executing a standard block within a `mounted` block with all dynamic params' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do mounted do desc configuration[:description] do headers configuration[:headers] @@ -191,7 +225,7 @@ def app context 'when executing a custom block on mount' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do get 'always' do 'success' end @@ -215,7 +249,7 @@ def app context 'when the configuration is part of the arguments of a method' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do get configuration[:endpoint_name] do 'success' end @@ -228,7 +262,7 @@ def app expect(last_response.body).to eq 'success' get '/different_location' - expect(last_response.status).to eq 404 + expect(last_response).to be_not_found root_api.mount a_remounted_api, with: { endpoint_name: 'new_location' } get '/new_location' @@ -237,7 +271,7 @@ def app context 'when the configuration is the value in a key-arg pair' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do version 'v1', using: :param, parameter: configuration[:version_param] get 'endpoint' do 'version 1' @@ -267,24 +301,26 @@ def app context 'on the DescSCope' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do desc 'The description of this' do tags ['not_configurable_tag', configuration[:a_configurable_tag]] end get 'location' do - 'success' + route.tags end end end it 'mounts the endpoint with the appropiate tags' do root_api.mount({ a_remounted_api => 'integer' }, with: { a_configurable_tag: 'a configured tag' }) + get '/integer/location', param_key: 'a' + expect(JSON.parse(last_response.body)).to eq ['not_configurable_tag', 'a configured tag'] end end context 'on the ParamScope' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do params do requires configuration[:required_param], type: configuration[:required_type] end @@ -303,18 +339,18 @@ def app expect(last_response.body).to eq 'success' get '/string/location', param_integer: 1 - expect(last_response.status).to eq 400 + expect(last_response).to be_bad_request get '/integer/location', param_integer: 1 expect(last_response.body).to eq 'success' get '/integer/location', param_integer: 'a' - expect(last_response.status).to eq 400 + expect(last_response).to be_bad_request end context 'on dynamic checks' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do params do optional :restricted_values, values: -> { [configuration[:allowed_value], 'always'] } end @@ -332,7 +368,7 @@ def app get '/location', restricted_values: 'sometimes' expect(last_response.body).to eq 'success' get '/location', restricted_values: 'never' - expect(last_response.status).to eq 400 + expect(last_response).to be_bad_request end end end @@ -340,25 +376,30 @@ def app context 'when the configuration is read within a namespace' do before do a_remounted_api.namespace 'api' do + params do + requires configuration[:required_param] + end get "/#{configuration[:path]}" do '10 votes' end end - root_api.mount a_remounted_api, with: { path: 'votes' } - root_api.mount a_remounted_api, with: { path: 'scores' } + root_api.mount a_remounted_api, with: { path: 'votes', required_param: 'param_key' } + root_api.mount a_remounted_api, with: { path: 'scores', required_param: 'param_key' } end - it 'will use the dynamic configuration on all routes' do - get 'api/votes' + it 'uses the dynamic configuration on all routes' do + get 'api/votes', param_key: 'a' expect(last_response.body).to eql '10 votes' - get 'api/scores' + get 'api/scores', param_key: 'a' expect(last_response.body).to eql '10 votes' + get 'api/votes' + expect(last_response).to be_bad_request end end context 'a very complex configuration example' do before do - top_level_api = Class.new(Grape::API) do + top_level_api = Class.new(described_class) do remounted_api = Class.new(Grape::API) do get configuration[:endpoint_name] do configuration[:response] @@ -426,7 +467,7 @@ def app context 'when the configuration is read in a helper' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do helpers do def printed_response configuration[:some_value] @@ -439,7 +480,7 @@ def printed_response end end - it 'will use the dynamic configuration on all routes' do + it 'uses the dynamic configuration on all routes' do root_api.mount(a_remounted_api, with: { some_value: 'response value' }) get '/location' @@ -449,14 +490,14 @@ def printed_response context 'when the configuration is read within the response block' do subject(:a_remounted_api) do - Class.new(Grape::API) do + Class.new(described_class) do get 'location' do configuration[:some_value] end end end - it 'will use the dynamic configuration on all routes' do + it 'uses the dynamic configuration on all routes' do root_api.mount(a_remounted_api, with: { some_value: 'response value' }) get '/location' @@ -464,5 +505,65 @@ def printed_response end end end + + context 'with route settings' do + before do + a_remounted_api.desc 'Identical description' + a_remounted_api.route_setting :custom, key: 'value' + a_remounted_api.route_setting :custom_diff, key: 'foo' + a_remounted_api.get '/api1' do + status 200 + end + + a_remounted_api.desc 'Identical description' + a_remounted_api.route_setting :custom, key: 'value' + a_remounted_api.route_setting :custom_diff, key: 'bar' + a_remounted_api.get '/api2' do + status 200 + end + end + + it 'has all the settings for both routes' do + expect(a_remounted_api.routes.count).to be(2) + expect(a_remounted_api.routes[0].settings).to include( + { + description: { description: 'Identical description' }, + custom: { key: 'value' }, + custom_diff: { key: 'foo' } + } + ) + expect(a_remounted_api.routes[1].settings).to include( + { + description: { description: 'Identical description' }, + custom: { key: 'value' }, + custom_diff: { key: 'bar' } + } + ) + end + + context 'when mounting it' do + before do + root_api.mount a_remounted_api + end + + it 'still has all the settings for both routes' do + expect(root_api.routes.count).to be(2) + expect(root_api.routes[0].settings).to include( + { + description: { description: 'Identical description' }, + custom: { key: 'value' }, + custom_diff: { key: 'foo' } + } + ) + expect(root_api.routes[1].settings).to include( + { + description: { description: 'Identical description' }, + custom: { key: 'value' }, + custom_diff: { key: 'bar' } + } + ) + end + end + end end end diff --git a/spec/grape/api_spec.rb b/spec/grape/api_spec.rb index 4d9325671..f16b31ee2 100644 --- a/spec/grape/api_spec.rb +++ b/spec/grape/api_spec.rb @@ -1,14 +1,11 @@ # frozen_string_literal: true -require 'spec_helper' require 'shared/versioning_examples' describe Grape::API do - subject { Class.new(Grape::API) } + subject { Class.new(described_class) } - def app - subject - end + let(:app) { subject } describe '.prefix' do it 'routes root through with the prefix' do @@ -18,7 +15,7 @@ def app end get 'awesome/sauce/' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eql 'Hello there.' end @@ -32,7 +29,7 @@ def app expect(last_response.body).to eql 'Hello there.' get '/hello' - expect(last_response.status).to eql 404 + expect(last_response).to be_not_found end it 'supports OPTIONS' do @@ -42,7 +39,7 @@ def app end options 'awesome/sauce' - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content expect(last_response.body).to be_blank end @@ -51,7 +48,7 @@ def app subject.get post 'awesome/sauce' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed end end @@ -71,7 +68,7 @@ def app end describe '.version using path' do - it_should_behave_like 'versioning' do + it_behaves_like 'versioning' do let(:macro_options) do { using: :path @@ -81,7 +78,7 @@ def app end describe '.version using param' do - it_should_behave_like 'versioning' do + it_behaves_like 'versioning' do let(:macro_options) do { using: :param, @@ -92,7 +89,7 @@ def app end describe '.version using header' do - it_should_behave_like 'versioning' do + it_behaves_like 'versioning' do let(:macro_options) do { using: :header, @@ -101,26 +98,10 @@ def app } end end - - # Behavior as defined by rfc2616 when no header is defined - # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html - describe 'no specified accept header' do - # subject.version 'v1', using: :header - # subject.get '/hello' do - # 'hello' - # end - - # it 'routes' do - # get '/hello' - # last_response.status.should eql 200 - # end - end - - # pending 'routes if any media type is allowed' end describe '.version using accept_version_header' do - it_should_behave_like 'versioning' do + it_behaves_like 'versioning' do let(:macro_options) do { using: :accept_version_header @@ -255,9 +236,9 @@ def app end get '/users/michael' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found get '/users/23' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful end context 'with param type definitions' do @@ -306,8 +287,10 @@ def subject.enable_root_route! end end - after do - expect(last_response.body).to eql 'root' + shared_examples_for 'a root route' do + it 'returns root' do + expect(last_response.body).to eql 'root' + end end describe 'path versioned APIs' do @@ -319,56 +302,104 @@ def subject.enable_root_route! context 'when a single version provided' do let(:version) { 'v1' } - it 'without a format' do - versioned_get '/', 'v1', using: :path + context 'without a format' do + before do + versioned_get '/', 'v1', using: :path + end + + it_behaves_like 'a root route' end - it 'with a format' do - get '/v1/.json' + context 'with a format' do + before do + get '/v1/.json' + end + + it_behaves_like 'a root route' end end context 'when array of versions provided' do let(:version) { %w[v1 v2] } - it { versioned_get '/', 'v1', using: :path } - it { versioned_get '/', 'v2', using: :path } + context 'when v1' do + before do + versioned_get '/', 'v1', using: :path + end + + it_behaves_like 'a root route' + end + + context 'when v2' do + before do + versioned_get '/', 'v2', using: :path + end + + it_behaves_like 'a root route' + end end end - it 'header versioned APIs' do - subject.version 'v1', using: :header, vendor: 'test' - subject.enable_root_route! + context 'when header versioned APIs' do + before do + subject.version 'v1', using: :header, vendor: 'test' + subject.enable_root_route! + versioned_get '/', 'v1', using: :header, vendor: 'test' + end - versioned_get '/', 'v1', using: :header, vendor: 'test' + it_behaves_like 'a root route' end - it 'header versioned APIs with multiple headers' do - subject.version %w[v1 v2], using: :header, vendor: 'test' - subject.enable_root_route! + context 'when header versioned APIs with multiple headers' do + before do + subject.version %w[v1 v2], using: :header, vendor: 'test' + subject.enable_root_route! + end + + context 'when v1' do + before do + versioned_get '/', 'v1', using: :header, vendor: 'test' + end + + it_behaves_like 'a root route' + end - versioned_get '/', 'v1', using: :header, vendor: 'test' - versioned_get '/', 'v2', using: :header, vendor: 'test' + context 'when v2' do + before do + versioned_get '/', 'v2', using: :header, vendor: 'test' + end + + it_behaves_like 'a root route' + end end - it 'param versioned APIs' do - subject.version 'v1', using: :param - subject.enable_root_route! + context 'param versioned APIs' do + before do + subject.version 'v1', using: :param + subject.enable_root_route! + versioned_get '/', 'v1', using: :param + end - versioned_get '/', 'v1', using: :param + it_behaves_like 'a root route' end - it 'Accept-Version header versioned APIs' do - subject.version 'v1', using: :accept_version_header - subject.enable_root_route! + context 'when Accept-Version header versioned APIs' do + before do + subject.version 'v1', using: :accept_version_header + subject.enable_root_route! + versioned_get '/', 'v1', using: :accept_version_header + end - versioned_get '/', 'v1', using: :accept_version_header + it_behaves_like 'a root route' end - it 'unversioned APIs' do - subject.enable_root_route! + context 'unversioned APIss' do + before do + subject.enable_root_route! + get '/' + end - get '/' + it_behaves_like 'a root route' end end @@ -384,29 +415,31 @@ def subject.enable_root_route! end context 'format' do - module ApiSpec - class DummyFormatClass - end - end + before do + dummy_class = Class.new do + def to_json(*_rest) + 'abc' + end - before(:each) do - allow_any_instance_of(ApiSpec::DummyFormatClass).to receive(:to_json).and_return('abc') - allow_any_instance_of(ApiSpec::DummyFormatClass).to receive(:to_txt).and_return('def') + def to_txt + 'def' + end + end subject.get('/abc') do - ApiSpec::DummyFormatClass.new + dummy_class.new end end it 'allows .json' do get '/abc.json' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eql 'abc' # json-encoded symbol end it 'allows .txt' do get '/abc.txt' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eql 'def' # raw text end end @@ -446,37 +479,40 @@ class DummyFormatClass expect(last_response.body).to eql 'hiya' end + objects = ['string', :symbol, 1, -1.1, {}, [], true, false, nil].freeze %i[put post].each do |verb| - context verb do - ['string', :symbol, 1, -1.1, {}, [], true, false, nil].each do |object| + context verb.to_s do + objects.each do |object| it "allows a(n) #{object.class} json object in params" do subject.format :json subject.send(verb) do - env['api.request.body'] + env[Grape::Env::API_REQUEST_BODY] end - send verb, '/', ::Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json' + send verb, '/', Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json' expect(last_response.status).to eq(verb == :post ? 201 : 200) - expect(last_response.body).to eql ::Grape::Json.dump(object) + expect(last_response.body).to eql Grape::Json.dump(object) expect(last_request.params).to eql({}) end + it 'stores input in api.request.input' do subject.format :json subject.send(verb) do - env['api.request.input'] + env[Grape::Env::API_REQUEST_INPUT] end - send verb, '/', ::Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json' + send verb, '/', Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json' expect(last_response.status).to eq(verb == :post ? 201 : 200) - expect(last_response.body).to eql ::Grape::Json.dump(object).to_json + expect(last_response.body).to eql Grape::Json.dump(object).to_json end + context 'chunked transfer encoding' do it 'stores input in api.request.input' do subject.format :json subject.send(verb) do - env['api.request.input'] + env[Grape::Env::API_REQUEST_INPUT] end - send verb, '/', ::Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json', 'HTTP_TRANSFER_ENCODING' => 'chunked', 'CONTENT_LENGTH' => nil + send verb, '/', Grape::Json.dump(object), 'CONTENT_TYPE' => 'application/json', 'HTTP_TRANSFER_ENCODING' => 'chunked' expect(last_response.status).to eq(verb == :post ? 201 : 200) - expect(last_response.body).to eql ::Grape::Json.dump(object).to_json + expect(last_response.body).to eql Grape::Json.dump(object).to_json end end end @@ -562,7 +598,8 @@ class DummyFormatClass send(other_verb, '/example') expected_rc = if other_verb == 'options' then 204 elsif other_verb == 'head' && verb == 'get' then 200 - else 405 + else + 405 end expect(last_response.status).to eql expected_rc end @@ -575,7 +612,7 @@ class DummyFormatClass end post '/example' - expect(last_response.status).to eql 201 + expect(last_response).to be_created expect(last_response.body).to eql 'Created' end @@ -585,7 +622,7 @@ class DummyFormatClass 'example' end put '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed expect(last_response.body).to eql '405 Not Allowed' expect(last_response.headers['X-Custom-Header']).to eql 'foo' end @@ -593,15 +630,17 @@ class DummyFormatClass it 'runs only the before filter on 405 bad method' do subject.namespace :example do before { header 'X-Custom-Header', 'foo' } + before_validation { raise 'before_validation filter should not run' } after_validation { raise 'after_validation filter should not run' } after { raise 'after filter should not run' } + params { requires :only_for_get } get end post '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed expect(last_response.headers['X-Custom-Header']).to eql 'foo' end @@ -610,29 +649,33 @@ class DummyFormatClass subject.namespace :example do before do raise 'before filter ran twice' if already_run + already_run = true header 'X-Custom-Header', 'foo' end + get end post '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed expect(last_response.headers['X-Custom-Header']).to eql 'foo' end it 'runs all filters and body with a custom OPTIONS method' do subject.namespace :example do before { header 'X-Custom-Header-1', 'foo' } + before_validation { header 'X-Custom-Header-2', 'foo' } after_validation { header 'X-Custom-Header-3', 'foo' } after { header 'X-Custom-Header-4', 'foo' } + options { 'yup' } get end options '/example' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eql 'yup' expect(last_response.headers['Allow']).to be_nil expect(last_response.headers['X-Custom-Header-1']).to eql 'foo' @@ -649,13 +692,13 @@ class DummyFormatClass end put '/example' - expect(last_response.status).to eql 405 - expect(last_response.body).to eq <<-XML - - - 405 Not Allowed - -XML + expect(last_response).to be_method_not_allowed + expect(last_response.body).to eq <<~XML + + + 405 Not Allowed + + XML end end @@ -669,7 +712,7 @@ class DummyFormatClass 'example' end put '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed expect(last_response.body).to eql '405 Not Allowed' end end @@ -693,7 +736,7 @@ class DummyFormatClass 'example' end put '/example' - expect(last_response.headers['Content-Type']).to eql 'text/plain' + expect(last_response.content_type).to eql 'text/plain' end describe 'adds an OPTIONS route that' do @@ -713,7 +756,7 @@ class DummyFormatClass end it 'returns a 204' do - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content end it 'has an empty body' do @@ -749,11 +792,53 @@ class DummyFormatClass end end + describe 'when a resource routes by POST, GET, PATCH, PUT, and DELETE' do + before do + subject.namespace :example do + get do + 'example' + end + + patch do + 'example' + end + + post do + 'example' + end + + delete do + 'example' + end + + put do + 'example' + end + end + options '/example' + end + + describe 'it adds an OPTIONS route for namespaced endpoints that' do + it 'returns a 204' do + expect(last_response).to be_no_content + end + + it 'has an empty body' do + expect(last_response.body).to be_blank + end + + it 'has an Allow header' do + expect(last_response.headers['Allow']).to eql 'OPTIONS, GET, PATCH, POST, DELETE, PUT, HEAD' + end + end + end + describe 'adds an OPTIONS route for namespaced endpoints that' do before do subject.before { header 'X-Custom-Header', 'foo' } subject.namespace :example do before { header 'X-Custom-Header-2', 'foo' } + get :inner do 'example/inner' end @@ -762,7 +847,7 @@ class DummyFormatClass end it 'returns a 204' do - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content end it 'has an empty body' do @@ -800,7 +885,7 @@ class DummyFormatClass end it 'returns a 405' do - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed end it 'contains error message in body' do @@ -816,7 +901,7 @@ class DummyFormatClass end end - describe 'when hook behaviour is controlled by attributes on the route ' do + describe 'when hook behaviour is controlled by attributes on the route' do before do subject.before do error!('Access Denied', 401) unless route.options[:secret] == params[:secret] @@ -839,28 +924,31 @@ class DummyFormatClass let(:response) { delete('/example') } it 'responds with a 405 status' do - expect(response.status).to eql 405 + expect(response).to be_method_not_allowed end end context 'when HTTP method is defined with attribute' do let(:response) { post('/example?secret=incorrect_password') } + it 'responds with the defined error in the before hook' do - expect(response.status).to eql 401 + expect(response).to be_unauthorized end end context 'when HTTP method is defined and the underlying before hook expectation is not met' do let(:response) { post('/example?secret=password&namespace_secret=wrong_namespace_password') } + it 'ends up in the endpoint' do - expect(response.status).to eql 401 + expect(response).to be_unauthorized end end context 'when HTTP method is defined and everything is like the before hooks expect' do let(:response) { post('/example?secret=password&namespace_secret=namespace_password') } + it 'ends up in the endpoint' do - expect(response.status).to eql 201 + expect(response).to be_created end end @@ -868,7 +956,7 @@ class DummyFormatClass let(:response) { head('/example?id=504') } it 'responds with 401 because before expectations in before hooks are not met' do - expect(response.status).to eql 401 + expect(response).to be_unauthorized end end @@ -876,7 +964,7 @@ class DummyFormatClass let(:response) { head('/example?id=504&secret=password') } it 'responds with 200 because before hooks are not called' do - expect(response.status).to eql 200 + expect(response).to be_successful end end end @@ -893,7 +981,7 @@ class DummyFormatClass end it 'returns a 200' do - expect(last_response.status).to eql 200 + expect(last_response).to be_successful end it 'has an empty body' do @@ -909,31 +997,33 @@ class DummyFormatClass 'example' end head '/example' - expect(last_response.status).to eql 400 + expect(last_response).to be_bad_request end end context 'do_not_route_head!' do - before :each do + before do subject.do_not_route_head! subject.get 'example' do 'example' end end + it 'options does not contain HEAD' do options '/example' - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content expect(last_response.body).to eql '' expect(last_response.headers['Allow']).to eql 'OPTIONS, GET' end + it 'does not allow HEAD on a GET request' do head '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed end end context 'do_not_route_options!' do - before :each do + before do subject.do_not_route_options! subject.get 'example' do 'example' @@ -942,25 +1032,25 @@ class DummyFormatClass it 'does not create an OPTIONS route' do options '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed end it 'does not include OPTIONS in Allow header' do options '/example' - expect(last_response.status).to eql 405 + expect(last_response).to be_method_not_allowed expect(last_response.headers['Allow']).to eql 'GET, HEAD' end end describe '.compile!' do - it 'requires the grape/eager_load file' do - expect(app).to receive(:require).with('grape/eager_load') { nil } - app.compile! + let(:base_instance) { app.base_instance } + + before do + allow(base_instance).to receive(:compile!).and_return(:compiled!) end - it 'compiles the instance for rack!' do - stubbed_object = double(:instance_for_rack) - allow(app).to receive(:instance_for_rack) { stubbed_object } + it 'returns compiled!' do + expect(app.send(:compile!)).to eq(:compiled!) end end @@ -976,7 +1066,7 @@ class DummyFormatClass context 'when the app was mounted' do it 'returns the first mounted instance' do mounted_app = app - Class.new(Grape::API) do + Class.new(described_class) do namespace 'new_namespace' do mount mounted_app end @@ -1004,6 +1094,7 @@ class DummyFormatClass end subject.namespace :blah do before { @foo = 'foo' } + get '/' do "blah - #{@foo}" end @@ -1045,7 +1136,7 @@ class DummyFormatClass @var ||= 'default' end - expect(m).to receive(:do_something!).exactly(2).times + expect(m).to receive(:do_something!).twice get '/' expect(last_response.body).to eql 'default' end @@ -1061,21 +1152,23 @@ class DummyFormatClass end subject.resource ':id' do before { a.do_something! } + before_validation { b.do_something! } after_validation { c.do_something! } after { d.do_something! } + get do 'got it' end end - expect(a).to receive(:do_something!).exactly(1).times - expect(b).to receive(:do_something!).exactly(1).times - expect(c).to receive(:do_something!).exactly(1).times - expect(d).to receive(:do_something!).exactly(1).times + expect(a).to receive(:do_something!).once + expect(b).to receive(:do_something!).once + expect(c).to receive(:do_something!).once + expect(d).to receive(:do_something!).once get '/123' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eql 'got it' end @@ -1086,26 +1179,28 @@ class DummyFormatClass d = double('after mock') subject.params do - requires :id, type: Integer + requires :id, type: Integer, values: [1, 2, 3] end subject.resource ':id' do before { a.do_something! } + before_validation { b.do_something! } after_validation { c.do_something! } after { d.do_something! } + get do 'got it' end end - expect(a).to receive(:do_something!).exactly(1).times - expect(b).to receive(:do_something!).exactly(1).times + expect(a).to receive(:do_something!).once + expect(b).to receive(:do_something!).once expect(c).to receive(:do_something!).exactly(0).times expect(d).to receive(:do_something!).exactly(0).times - get '/abc' - expect(last_response.status).to eql 400 - expect(last_response.body).to eql 'id is invalid' + get '/4' + expect(last_response).to be_bad_request + expect(last_response.body).to eql 'id does not have a valid value' end it 'calls filters in the correct order' do @@ -1120,21 +1215,23 @@ class DummyFormatClass end subject.resource ':id' do before { a.here(i += 1) } + before_validation { b.here(i += 1) } after_validation { c.here(i += 1) } after { d.here(i += 1) } + get do 'got it' end end - expect(a).to receive(:here).with(1).exactly(1).times - expect(b).to receive(:here).with(2).exactly(1).times - expect(c).to receive(:here).with(3).exactly(1).times - expect(d).to receive(:here).with(4).exactly(1).times + expect(a).to receive(:here).with(1).once + expect(b).to receive(:here).with(2).once + expect(c).to receive(:here).with(3).once + expect(d).to receive(:here).with(4).once get '/123' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eql 'got it' end end @@ -1146,41 +1243,41 @@ class DummyFormatClass it 'sets content type for txt format' do get '/foo' - expect(last_response.headers['Content-Type']).to eq('text/plain') + expect(last_response.content_type).to eq('text/plain') end it 'does not set Cache-Control' do get '/foo' - expect(last_response.headers['Cache-Control']).to eq(nil) + expect(last_response.headers[Rack::CACHE_CONTROL]).to be_nil end it 'sets content type for xml' do get '/foo.xml' - expect(last_response.headers['Content-Type']).to eq('application/xml') + expect(last_response.content_type).to eq('application/xml') end it 'sets content type for json' do get '/foo.json' - expect(last_response.headers['Content-Type']).to eq('application/json') + expect(last_response.content_type).to eq('application/json') end it 'sets content type for serializable hash format' do get '/foo.serializable_hash' - expect(last_response.headers['Content-Type']).to eq('application/json') + expect(last_response.content_type).to eq('application/json') end it 'sets content type for binary format' do get '/foo.binary' - expect(last_response.headers['Content-Type']).to eq('application/octet-stream') + expect(last_response.content_type).to eq('application/octet-stream') end it 'returns raw data when content type binary' do image_filename = 'grape.png' - file = File.open(image_filename, 'rb', &:read) + file = File.binread(image_filename) subject.format :binary subject.get('/binary_file') { File.binread(image_filename) } get '/binary_file' - expect(last_response.headers['Content-Type']).to eq('application/octet-stream') + expect(last_response.content_type).to eq('application/octet-stream') expect(last_response.body).to eq(file) end @@ -1190,10 +1287,10 @@ class DummyFormatClass test_file.write file_content test_file.rewind - subject.get('/file') { file test_file } + subject.get('/file') { stream test_file } get '/file' - expect(last_response.headers['Content-Length']).to eq('25') - expect(last_response.headers['Content-Type']).to eq('text/plain') + expect(last_response.content_length).to eq(25) + expect(last_response.content_type).to eq('text/plain') expect(last_response.body).to eq(file_content) end @@ -1203,13 +1300,13 @@ class DummyFormatClass blk.yield ' file content' end - subject.use Rack::Chunked + subject.use Gem::Version.new(Rack.release) < Gem::Version.new('3') ? Rack::Chunked : ChunkedResponse subject.get('/stream') { stream test_stream } - get '/stream', {}, 'HTTP_VERSION' => 'HTTP/1.1', 'SERVER_PROTOCOL' => 'HTTP/1.1' + get '/stream', {}, 'HTTP_VERSION' => 'HTTP/1.1', Rack::SERVER_PROTOCOL => 'HTTP/1.1' - expect(last_response.headers['Content-Type']).to eq('text/plain') - expect(last_response.headers['Content-Length']).to eq(nil) - expect(last_response.headers['Cache-Control']).to eq('no-cache') + expect(last_response.content_type).to eq('text/plain') + expect(last_response.content_length).to be_nil + expect(last_response.headers[Rack::CACHE_CONTROL]).to eq('no-cache') expect(last_response.headers['Transfer-Encoding']).to eq('chunked') expect(last_response.body).to eq("c\r\nThis is some\r\nd\r\n file content\r\n0\r\n\r\n") @@ -1218,30 +1315,30 @@ class DummyFormatClass it 'sets content type for error' do subject.get('/error') { error!('error in plain text', 500) } get '/error' - expect(last_response.headers['Content-Type']).to eql 'text/plain' + expect(last_response.content_type).to eql 'text/plain' end it 'sets content type for json error' do subject.format :json subject.get('/error') { error!('error in json', 500) } get '/error.json' - expect(last_response.status).to eql 500 - expect(last_response.headers['Content-Type']).to eql 'application/json' + expect(last_response).to be_server_error + expect(last_response.content_type).to eql 'application/json' end it 'sets content type for xml error' do subject.format :xml subject.get('/error') { error!('error in xml', 500) } get '/error' - expect(last_response.status).to eql 500 - expect(last_response.headers['Content-Type']).to eql 'application/xml' + expect(last_response).to be_server_error + expect(last_response.content_type).to eql 'application/xml' end it 'includes extension in format' do subject.get(':id') { params[:format] } get '/baz.bar' - expect(last_response.status).to eq 200 + expect(last_response).to be_successful expect(last_response.body).to eq 'bar' end @@ -1250,7 +1347,7 @@ class DummyFormatClass subject.get(':id') { params } get '/baz.bar' - expect(last_response.status).to eq 404 + expect(last_response).to be_not_found end context 'with a custom content_type' do @@ -1264,55 +1361,64 @@ class DummyFormatClass it 'sets content type' do get '/custom.custom' - expect(last_response.headers['Content-Type']).to eql 'application/custom' + expect(last_response.content_type).to eql 'application/custom' end it 'sets content type for error' do get '/error.custom' - expect(last_response.headers['Content-Type']).to eql 'application/custom' + expect(last_response.content_type).to eql 'application/custom' end end context 'env["api.format"]' do before do + ct = content_type subject.post 'attachment' do filename = params[:file][:filename] - content_type MIME::Types.type_for(filename)[0].to_s - env['api.format'] = :binary # there's no formatter for :binary, data will be returned "as is" + content_type ct + env[Grape::Env::API_FORMAT] = :binary # there's no formatter for :binary, data will be returned "as is" header 'Content-Disposition', "attachment; filename*=UTF-8''#{CGI.escape(filename)}" params[:file][:tempfile].read end end - ['/attachment.png', 'attachment'].each do |url| - it "uploads and downloads a PNG file via #{url}" do - image_filename = 'grape.png' - post url, file: Rack::Test::UploadedFile.new(image_filename, 'image/png', true) - expect(last_response.status).to eq(201) - expect(last_response.headers['Content-Type']).to eq('image/png') - expect(last_response.headers['Content-Disposition']).to eq("attachment; filename*=UTF-8''grape.png") - File.open(image_filename, 'rb') do |io| - expect(last_response.body).to eq io.read + context 'when image/png' do + let(:content_type) { 'image/png' } + + %w[/attachment.png attachment].each do |url| + it "uploads and downloads a PNG file via #{url}" do + image_filename = 'grape.png' + post url, file: Rack::Test::UploadedFile.new(image_filename, content_type, true) + expect(last_response).to be_created + expect(last_response.content_type).to eq(content_type) + expect(last_response.headers['Content-Disposition']).to eq("attachment; filename*=UTF-8''grape.png") + File.open(image_filename, 'rb') do |io| + expect(last_response.body).to eq io.read + end end end end - it 'uploads and downloads a Ruby file' do - filename = __FILE__ - post '/attachment.rb', file: Rack::Test::UploadedFile.new(filename, 'application/x-ruby', true) - expect(last_response.status).to eq(201) - expect(last_response.headers['Content-Type']).to eq('application/x-ruby') - expect(last_response.headers['Content-Disposition']).to eq("attachment; filename*=UTF-8''api_spec.rb") - File.open(filename, 'rb') do |io| - expect(last_response.body).to eq io.read + context 'when ruby file' do + let(:content_type) { 'application/x-ruby' } + + it 'uploads and downloads a Ruby file' do + filename = __FILE__ + post '/attachment.rb', file: Rack::Test::UploadedFile.new(filename, content_type, true) + expect(last_response).to be_created + expect(last_response.content_type).to eq(content_type) + expect(last_response.headers['Content-Disposition']).to eq("attachment; filename*=UTF-8''api_spec.rb") + File.open(filename, 'rb') do |io| + expect(last_response.body).to eq io.read + end end end end end context 'custom middleware' do - module ApiSpec - class PhonyMiddleware + let(:phony_middleware) do + Class.new do def initialize(app, *args) @args = args @app = app @@ -1330,43 +1436,44 @@ def call(env) describe '.middleware' do it 'includes middleware arguments from settings' do - subject.use ApiSpec::PhonyMiddleware, 'abc', 123 - expect(subject.middleware).to eql [[:use, ApiSpec::PhonyMiddleware, 'abc', 123]] + subject.use phony_middleware, 'abc', 123 + expect(subject.middleware).to eql [[:use, phony_middleware, 'abc', 123]] end it 'includes all middleware from stacked settings' do - subject.use ApiSpec::PhonyMiddleware, 123 - subject.use ApiSpec::PhonyMiddleware, 'abc' - subject.use ApiSpec::PhonyMiddleware, 'foo' + subject.use phony_middleware, 123 + subject.use phony_middleware, 'abc' + subject.use phony_middleware, 'foo' expect(subject.middleware).to eql [ - [:use, ApiSpec::PhonyMiddleware, 123], - [:use, ApiSpec::PhonyMiddleware, 'abc'], - [:use, ApiSpec::PhonyMiddleware, 'foo'] + [:use, phony_middleware, 123], + [:use, phony_middleware, 'abc'], + [:use, phony_middleware, 'foo'] ] end end describe '.use' do it 'adds middleware' do - subject.use ApiSpec::PhonyMiddleware, 123 - expect(subject.middleware).to eql [[:use, ApiSpec::PhonyMiddleware, 123]] + subject.use phony_middleware, 123 + expect(subject.middleware).to eql [[:use, phony_middleware, 123]] end it 'does not show up outside the namespace' do + example = self inner_middleware = nil - subject.use ApiSpec::PhonyMiddleware, 123 + subject.use phony_middleware, 123 subject.namespace :awesome do - use ApiSpec::PhonyMiddleware, 'abc' + use example.phony_middleware, 'abc' inner_middleware = middleware end - expect(subject.middleware).to eql [[:use, ApiSpec::PhonyMiddleware, 123]] - expect(inner_middleware).to eql [[:use, ApiSpec::PhonyMiddleware, 123], [:use, ApiSpec::PhonyMiddleware, 'abc']] + expect(subject.middleware).to eql [[:use, phony_middleware, 123]] + expect(inner_middleware).to eql [[:use, phony_middleware, 123], [:use, phony_middleware, 'abc']] end it 'calls the middleware' do - subject.use ApiSpec::PhonyMiddleware, 'hello' + subject.use phony_middleware, 'hello' subject.get '/' do env['phony.args'].first.first end @@ -1377,13 +1484,13 @@ def call(env) it 'adds a block if one is given' do block = -> {} - subject.use ApiSpec::PhonyMiddleware, &block - expect(subject.middleware).to eql [[:use, ApiSpec::PhonyMiddleware, block]] + subject.use phony_middleware, &block + expect(subject.middleware).to eql [[:use, phony_middleware, block]] end it 'uses a block if one is given' do block = -> {} - subject.use ApiSpec::PhonyMiddleware, &block + subject.use phony_middleware, &block subject.get '/' do env['phony.block'].inspect end @@ -1394,7 +1501,7 @@ def call(env) it 'does not destroy the middleware settings on multiple runs' do block = -> {} - subject.use ApiSpec::PhonyMiddleware, &block + subject.use phony_middleware, &block subject.get '/' do env['phony.block'].inspect end @@ -1415,9 +1522,37 @@ def before subject.get '/' do end get '/' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('Caught in the Net') end + + context 'when middleware initialize as keywords' do + let(:middleware_with_keywords) do + Class.new do + def initialize(app, keyword:) + @app = app + @keyword = keyword + end + + def call(env) + env['middleware_with_keywords'] = @keyword + @app.call(env) + end + end + end + + before do + subject.use middleware_with_keywords, keyword: 'hello' + subject.get '/' do + env['middleware_with_keywords'] + end + get '/' + end + + it 'returns the middleware value' do + expect(last_response.body).to eq('hello') + end + end end describe '.insert_before' do @@ -1430,8 +1565,8 @@ def call(env) end end - subject.use ApiSpec::PhonyMiddleware, 'hello' - subject.insert_before ApiSpec::PhonyMiddleware, m, message: 'bye' + subject.use phony_middleware, 'hello' + subject.insert_before phony_middleware, m, message: 'bye' subject.get '/' do env['phony.args'].join(' ') end @@ -1451,8 +1586,8 @@ def call(env) end end - subject.use ApiSpec::PhonyMiddleware, 'hello' - subject.insert_after ApiSpec::PhonyMiddleware, m, message: 'bye' + subject.use phony_middleware, 'hello' + subject.insert_after phony_middleware, m, message: 'bye' subject.get '/' do env['phony.args'].join(' ') end @@ -1461,27 +1596,27 @@ def call(env) expect(last_response.body).to eql 'hello bye' end end - end - describe '.insert' do - it 'inserts middleware in a specific location in the stack' do - m = Class.new(Grape::Middleware::Base) do - def call(env) - env['phony.args'] ||= [] - env['phony.args'] << @options[:message] - @app.call(env) + describe '.insert' do + it 'inserts middleware in a specific location in the stack' do + m = Class.new(Grape::Middleware::Base) do + def call(env) + env['phony.args'] ||= [] + env['phony.args'] << @options[:message] + @app.call(env) + end end - end - subject.use ApiSpec::PhonyMiddleware, 'bye' - subject.insert 0, m, message: 'good' - subject.insert 0, m, message: 'hello' - subject.get '/' do - env['phony.args'].join(' ') - end + subject.use phony_middleware, 'bye' + subject.insert 0, m, message: 'good' + subject.insert 0, m, message: 'hello' + subject.get '/' do + env['phony.args'].join(' ') + end - get '/' - expect(last_response.body).to eql 'hello good bye' + get '/' + expect(last_response.body).to eql 'hello good bye' + end end end @@ -1492,9 +1627,9 @@ def call(env) end subject.get(:hello) { 'Hello, world.' } get '/hello' - expect(last_response.status).to eql 401 + expect(last_response).to be_unauthorized get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('allow', 'whatever') - expect(last_response.status).to eql 200 + expect(last_response).to be_successful end it 'is scopable' do @@ -1508,9 +1643,9 @@ def call(env) end get '/hello' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful get '/admin/hello' - expect(last_response.status).to eql 401 + expect(last_response).to be_unauthorized end it 'is callable via .auth as well' do @@ -1520,9 +1655,9 @@ def call(env) subject.get(:hello) { 'Hello, world.' } get '/hello' - expect(last_response.status).to eql 401 + expect(last_response).to be_unauthorized get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('allow', 'whatever') - expect(last_response.status).to eql 200 + expect(last_response).to be_successful end it 'has access to the current endpoint' do @@ -1536,13 +1671,13 @@ def call(env) subject.get(:hello) { 'Hello, world.' } get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('allow', 'whatever') - expect(basic_auth_context).to be_a_kind_of(Grape::Endpoint) + expect(basic_auth_context).to be_a(Grape::Endpoint) end it 'has access to helper methods' do subject.helpers do - def authorize(u, p) - u == 'allow' && p == 'whatever' + def authorize(user, password) + user == 'allow' && password == 'whatever' end end @@ -1552,9 +1687,9 @@ def authorize(u, p) subject.get(:hello) { 'Hello, world.' } get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('allow', 'whatever') - expect(last_response.status).to eql 200 + expect(last_response).to be_successful get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('disallow', 'whatever') - expect(last_response.status).to eql 401 + expect(last_response).to be_unauthorized end it 'can set instance variables accessible to routes' do @@ -1566,39 +1701,31 @@ def authorize(u, p) subject.get(:hello) { @hello } get '/hello', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('allow', 'whatever') - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eql 'Hello, world.' end end describe '.logger' do - subject do - Class.new(Grape::API) do - def self.io - @io ||= StringIO.new - end - logger ::Logger.new(io) - end - end - it 'returns an instance of Logger class by default' do expect(subject.logger.class).to eql Logger end - it 'allows setting a custom logger' do - mylogger = Class.new - subject.logger mylogger - expect(mylogger).to receive(:info).exactly(1).times - subject.logger.info 'this will be logged' - end + context 'with a custom logger' do + subject do + Class.new(described_class) do + def self.io + @io ||= StringIO.new + end + logger Logger.new(io) + end + end - it 'defaults to a standard logger log format' do - t = Time.at(100) - allow(Time).to receive(:now).and_return(t) - message = "this will be logged\n" - message = "I, [#{Logger::Formatter.new.send(:format_datetime, t)}\##{Process.pid}] INFO -- : #{message}" if !defined?(Rails) || Gem::Version.new(Rails::VERSION::STRING) >= Gem::Version.new('4.0') - expect(subject.io).to receive(:write).with(message) - subject.logger.info 'this will be logged' + it 'exposes its interaface' do + message = 'this will be logged' + subject.logger.info message + expect(subject.io.string).to include(message) + end end end @@ -1724,13 +1851,13 @@ def three end get '/new/abc' - expect(last_response.status).to eql 404 + expect(last_response).to be_not_found get '/legacy/abc' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful get '/legacy/def' - expect(last_response.status).to eql 404 + expect(last_response).to be_not_found get '/new/def' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful end end @@ -1950,32 +2077,49 @@ def custom_error!(name) end context 'with multiple apis' do - let(:a) { Class.new(Grape::API) } - let(:b) { Class.new(Grape::API) } + let(:a) do + Class.new(described_class) do + namespace :a do + helpers do + def foo + error!('foo', 401) + end + end - before do - a.helpers do - def foo - error!('foo', 401) + rescue_from(:all) { foo } + + get { raise 'boo' } end end - a.rescue_from(:all) { foo } - a.get { raise 'boo' } - b.helpers do - def foo - error!('bar', 401) + end + let(:b) do + Class.new(described_class) do + namespace :b do + helpers do + def foo + error!('bar', 401) + end + end + + rescue_from(:all) { foo } + + get { raise 'boo' } end end - b.rescue_from(:all) { foo } - b.get { raise 'boo' } end - it 'avoids polluting global namespace' do - env = Rack::MockRequest.env_for('/') + before do + subject.mount a + subject.mount b + end - expect(read_chunks(a.call(env)[2])).to eq(['foo']) - expect(read_chunks(b.call(env)[2])).to eq(['bar']) - expect(read_chunks(a.call(env)[2])).to eq(['foo']) + it 'avoids polluting global namespace' do + get '/a' + expect(last_response.body).to eq('foo') + get '/b' + expect(last_response.body).to eq('bar') + get '/a' + expect(last_response.body).to eq('foo') end end @@ -1985,7 +2129,7 @@ def foo raise 'rain!' end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq 'rain!' end @@ -1997,7 +2141,7 @@ def foo raise 'rain!' end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq({ error: 'rain!' }.to_json) end @@ -2007,7 +2151,7 @@ def foo subject.get('/unrescued') { raise 'beefcake' } get '/rescued' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect { get '/unrescued' }.to raise_error(RuntimeError, 'beefcake') end @@ -2026,17 +2170,15 @@ def foo subject.get('/standard_error') { raise StandardError } get '/child_of_standard_error' - expect(last_response.status).to eql 402 + expect(last_response.status).to be 402 get '/standard_error' - expect(last_response.status).to eql 401 + expect(last_response).to be_unauthorized end context 'CustomError subclass of Grape::Exceptions::Base' do before do - module ApiSpec - class CustomError < Grape::Exceptions::Base; end - end + stub_const('ApiSpec::CustomError', Class.new(Grape::Exceptions::Base)) end it 'does not re-raise exceptions of type Grape::Exceptions::Base' do @@ -2047,14 +2189,14 @@ class CustomError < Grape::Exceptions::Base; end it 'rescues custom grape exceptions' do subject.rescue_from ApiSpec::CustomError do |e| - rack_response('New Error', e.status) + error!('New Error', e.status) end subject.get '/custom_error' do raise ApiSpec::CustomError.new(status: 400, message: 'Custom Error') end get '/custom_error' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('New Error') end end @@ -2065,85 +2207,88 @@ class CustomError < Grape::Exceptions::Base; end allow(Grape::Formatter).to receive(:formatter_for) { formatter } subject.rescue_from :all do |_e| - rack_response('Formatter Error', 500) + error!('Formatter Error', 500) end subject.get('/formatter_exception') { 'Hello world' } get '/formatter_exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('Formatter Error') end - it 'uses default_rescue_handler to handle invalid response from rescue_from' do - subject.rescue_from(:all) { 'error' } - subject.get('/') { raise } - - expect_any_instance_of(Grape::Middleware::Error).to receive(:default_rescue_handler).and_call_original - get '/' - expect(last_response.status).to eql 500 - expect(last_response.body).to eql 'Invalid response' + context 'when rescue_from block returns an invalid response' do + it 'returns a formatted response' do + subject.rescue_from(:all) { 'error' } + subject.get('/') { raise } + get '/' + expect(last_response).to be_server_error + expect(last_response.body).to eql 'Invalid response' + end end end describe '.rescue_from klass, block' do it 'rescues Exception' do subject.rescue_from RuntimeError do |e| - rack_response("rescued from #{e.message}", 202) + error!("rescued from #{e.message}", 202) end subject.get '/exception' do raise 'rain!' end get '/exception' - expect(last_response.status).to eql 202 + expect(last_response).to be_accepted expect(last_response.body).to eq('rescued from rain!') end context 'custom errors' do before do - class ConnectionError < RuntimeError; end - class DatabaseError < RuntimeError; end - class CommunicationError < StandardError; end + stub_const('ConnectionError', Class.new(RuntimeError)) + stub_const('DatabaseError', Class.new(RuntimeError)) + stub_const('CommunicationError', Class.new(StandardError)) end it 'rescues an error via rescue_from :all' do subject.rescue_from :all do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/exception' do raise ConnectionError end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('rescued from ConnectionError') end + it 'rescues a specific error' do subject.rescue_from ConnectionError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/exception' do raise ConnectionError end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('rescued from ConnectionError') end + it 'rescues a subclass of an error by default' do subject.rescue_from RuntimeError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/exception' do raise ConnectionError end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('rescued from ConnectionError') end + it 'rescues multiple specific errors' do subject.rescue_from ConnectionError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.rescue_from DatabaseError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/connection' do raise ConnectionError @@ -2152,15 +2297,16 @@ class CommunicationError < StandardError; end raise DatabaseError end get '/connection' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('rescued from ConnectionError') get '/database' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect(last_response.body).to eq('rescued from DatabaseError') end + it 'does not rescue a different error' do subject.rescue_from RuntimeError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/uncaught' do raise CommunicationError @@ -2173,23 +2319,23 @@ class CommunicationError < StandardError; end describe '.rescue_from klass, lambda' do it 'rescues an error with the lambda' do subject.rescue_from ArgumentError, lambda { - rack_response('rescued with a lambda', 400) + error!('rescued with a lambda', 400) } subject.get('/rescue_lambda') { raise ArgumentError } get '/rescue_lambda' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('rescued with a lambda') end it 'can execute the lambda with an argument' do subject.rescue_from ArgumentError, lambda { |e| - rack_response(e.message, 400) + error!(e.message, 400) } subject.get('/rescue_lambda') { raise ArgumentError, 'lambda takes an argument' } get '/rescue_lambda' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('lambda takes an argument') end end @@ -2211,11 +2357,11 @@ def rescue_no_method_error subject.get('/rescue_no_method_error') { raise NoMethodError } get '/rescue_arg_error' - expect(last_response.status).to eq(500) + expect(last_response).to be_server_error expect(last_response.body).to eq('500 ArgumentError') get '/rescue_no_method_error' - expect(last_response.status).to eq(500) + expect(last_response).to be_server_error expect(last_response.body).to eq('500 NoMethodError') end @@ -2223,7 +2369,7 @@ def rescue_no_method_error subject.rescue_from :all, with: :not_exist_method subject.get('/rescue_method') { raise StandardError } - expect { get '/rescue_method' }.to raise_error(NoMethodError, 'undefined method `not_exist_method\'') + expect { get '/rescue_method' }.to raise_error(NoMethodError, /^undefined method 'not_exist_method'/) end it 'correctly chooses exception handler if :all handler is specified' do @@ -2243,28 +2389,25 @@ def rescue_all_errors subject.get('/another_error') { raise NoMethodError } get '/argument_error' - expect(last_response.status).to eq(500) + expect(last_response).to be_server_error expect(last_response.body).to eq('500 ArgumentError') get '/another_error' - expect(last_response.status).to eq(500) + expect(last_response).to be_server_error expect(last_response.body).to eq('500 AnotherError') end end describe '.rescue_from klass, rescue_subclasses: boolean' do before do - module ApiSpec - module APIErrors - class ParentError < StandardError; end - class ChildError < ParentError; end - end - end + parent_error = Class.new(StandardError) + stub_const('ApiSpec::APIErrors::ParentError', parent_error) + stub_const('ApiSpec::APIErrors::ChildError', Class.new(parent_error)) end it 'rescues error as well as subclass errors with rescue_subclasses option set' do subject.rescue_from ApiSpec::APIErrors::ParentError, rescue_subclasses: true do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/caught_child' do raise ApiSpec::APIErrors::ChildError @@ -2277,27 +2420,27 @@ class ChildError < ParentError; end end get '/caught_child' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error get '/caught_parent' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error expect { get '/uncaught_parent' }.to raise_error(StandardError) end it 'sets rescue_subclasses to true by default' do subject.rescue_from ApiSpec::APIErrors::ParentError do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/caught_child' do raise ApiSpec::APIErrors::ChildError end get '/caught_child' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error end it 'does not rescue child errors if rescue_subclasses is false' do subject.rescue_from ApiSpec::APIErrors::ParentError, rescue_subclasses: false do |e| - rack_response("rescued from #{e.class.name}", 500) + error!("rescued from #{e.class.name}", 500) end subject.get '/uncaught' do raise ApiSpec::APIErrors::ChildError @@ -2327,7 +2470,7 @@ class ChildError < ParentError; end it 'rescues grape exceptions with a user-defined handler' do subject.rescue_from grape_exception.class do |_error| - rack_response('Redefined Error', 403) + error!('Redefined Error', 403) end exception = grape_exception @@ -2335,7 +2478,7 @@ class ChildError < ParentError; end get '/grape_exception' - expect(last_response.status).to eq(403) + expect(last_response).to be_forbidden expect(last_response.body).to eq('Redefined Error') end end @@ -2387,46 +2530,42 @@ class ChildError < ParentError; end end context 'class' do - before :each do - module ApiSpec - class CustomErrorFormatter - def self.call(message, _backtrace, _options, _env, _original_exception) - "message: #{message} @backtrace" - end + let(:custom_error_formatter) do + Class.new do + def self.call(message, _backtrace, _options, _env, _original_exception) + "message: #{message} @backtrace" end end end + it 'returns a custom error format' do subject.rescue_from :all, backtrace: true - subject.error_formatter :txt, ApiSpec::CustomErrorFormatter - subject.get '/exception' do - raise 'rain!' - end + subject.error_formatter :txt, custom_error_formatter + subject.get('/exception') { raise 'rain!' } + get '/exception' expect(last_response.body).to eq('message: rain! @backtrace') end - end - describe 'with' do - context 'class' do - before :each do - module ApiSpec - class CustomErrorFormatter - def self.call(message, _backtrace, _option, _env, _original_exception) - "message: #{message} @backtrace" - end - end - end - end + it 'returns a custom error format (using keyword :with)' do + subject.rescue_from :all, backtrace: true + subject.error_formatter :txt, with: custom_error_formatter + subject.get('/exception') { raise 'rain!' } - it 'returns a custom error format' do - subject.rescue_from :all, backtrace: true - subject.error_formatter :txt, with: ApiSpec::CustomErrorFormatter - subject.get('/exception') { raise 'rain!' } + get '/exception' + expect(last_response.body).to eq('message: rain! @backtrace') + end - get '/exception' - expect(last_response.body).to eq('message: rain! @backtrace') + it 'returns a modified error with a custom error format' do + subject.rescue_from :all, backtrace: true do |e| + error!('raining dogs and cats', 418, {}, e.backtrace, e) end + subject.error_formatter :txt, with: custom_error_formatter + subject.get '/exception' do + raise 'rain!' + end + get '/exception' + expect(last_response.body).to eq('message: raining dogs and cats @backtrace') end end @@ -2439,6 +2578,7 @@ def self.call(message, _backtrace, _option, _env, _original_exception) get '/exception' expect(last_response.body).to eql '{"error":"rain!"}' end + it 'rescues all errors and return :json with backtrace' do subject.rescue_from :all, backtrace: true subject.format :json @@ -2446,10 +2586,11 @@ def self.call(message, _backtrace, _option, _env, _original_exception) raise 'rain!' end get '/exception' - json = ::Grape::Json.load(last_response.body) + json = Grape::Json.load(last_response.body) expect(json['error']).to eql 'rain!' expect(json['backtrace'].length).to be > 0 end + it 'rescues error! and return txt' do subject.format :txt subject.get '/error' do @@ -2458,78 +2599,279 @@ def self.call(message, _backtrace, _option, _env, _original_exception) get '/error' expect(last_response.body).to eql 'Access Denied' end + context 'with json format' do - before { subject.format :json } + shared_examples_for 'a json format api' do |error_message| + subject { JSON.parse(last_response.body) } + + before { get '/error' } + + let(:app) do + Class.new(Grape::API) do + format :json + get('/error') { error!(error_message, 401) } + end + end - it 'rescues error! called with a string and returns json' do - subject.get('/error') { error!(:failure, 401) } + context "when error! called with #{error_message.class.name}" do + it { is_expected.to eq('error' => 'failure') } + end end - it 'rescues error! called with a symbol and returns json' do - subject.get('/error') { error!(:failure, 401) } + + it_behaves_like 'a json format api', 'failure' + it_behaves_like 'a json format api', :failure + it_behaves_like 'a json format api', { error: :failure } + end + + context 'when rescue_from enables backtrace without original exception' do + let(:app) do + response_type = response_format + + Class.new(Grape::API) do + format response_type + + rescue_from :all, backtrace: true, original_exception: false do |e| + error!('raining dogs and cats!', 418, {}, e.backtrace, e) + end + + get '/exception' do + raise 'rain!' + end + end end - it 'rescues error! called with a hash and returns json' do - subject.get('/error') { error!({ error: :failure }, 401) } + + before do + get '/exception' end - after do - get '/error' - expect(last_response.body).to eql('{"error":"failure"}') + context 'with json response type format' do + subject { JSON.parse(last_response.body) } + + let(:response_format) { :json } + + it { is_expected.to include('error' => a_kind_of(String), 'backtrace' => a_kind_of(Array)) } + it { is_expected.not_to include('original_exception') } end - end - end - describe '.content_type' do - it 'sets additional content-type' do - subject.content_type :xls, 'application/vnd.ms-excel' - subject.get :excel do - 'some binary content' + context 'with txt response type format' do + subject { last_response.body } + + let(:response_format) { :txt } + + it { is_expected.to include('backtrace') } + it { is_expected.not_to include('original_exception') } end - get '/excel.xls' - expect(last_response.content_type).to eq('application/vnd.ms-excel') - end - it 'allows to override content-type' do - subject.get :content do - content_type 'text/javascript' - 'var x = 1;' + + context 'with xml response type format' do + subject { Grape::Xml.parse(last_response.body)['error'] } + + let(:response_format) { :xml } + + it { is_expected.to have_key('backtrace') } + it { is_expected.not_to have_key('original-exception') } end - get '/content' - expect(last_response.content_type).to eq('text/javascript') end - it 'removes existing content types' do - subject.content_type :xls, 'application/vnd.ms-excel' - subject.get :excel do - 'some binary content' + + context 'when rescue_from enables original exception without backtrace' do + let(:app) do + response_type = response_format + + Class.new(Grape::API) do + format response_type + + rescue_from :all, backtrace: false, original_exception: true do |e| + error!('raining dogs and cats!', 418, {}, e.backtrace, e) + end + + get '/exception' do + raise 'rain!' + end + end end - get '/excel.json' - expect(last_response.status).to eq(406) - if ActiveSupport::VERSION::MAJOR == 3 - expect(last_response.body).to eq('The requested format 'txt' is not supported.') - else - expect(last_response.body).to eq('The requested format 'txt' is not supported.') + + before do + get '/exception' end - end - end + + context 'with json response type format' do + subject { JSON.parse(last_response.body) } + + let(:response_format) { :json } + + it { is_expected.to include('error' => a_kind_of(String), 'original_exception' => a_kind_of(String)) } + it { is_expected.not_to include('backtrace') } + end + + context 'with txt response type format' do + subject { last_response.body } + + let(:response_format) { :txt } + + it { is_expected.to include('original exception') } + it { is_expected.not_to include('backtrace') } + end + + context 'with xml response type format' do + subject { Grape::Xml.parse(last_response.body)['error'] } + + let(:response_format) { :xml } + + it { is_expected.to have_key('original-exception') } + it { is_expected.not_to have_key('backtrace') } + end + end + + context 'when rescue_from include backtrace and original exception' do + let(:app) do + response_type = response_format + + Class.new(Grape::API) do + format response_type + + rescue_from :all, backtrace: true, original_exception: true do |e| + error!('raining dogs and cats!', 418, {}, e.backtrace, e) + end + + get '/exception' do + raise 'rain!' + end + end + end + + before do + get '/exception' + end + + context 'with json response type format' do + subject { JSON.parse(last_response.body) } + + let(:response_format) { :json } + + it { is_expected.to include('error' => a_kind_of(String), 'backtrace' => a_kind_of(Array), 'original_exception' => a_kind_of(String)) } + end + + context 'with txt response type format' do + subject { last_response.body } + + let(:response_format) { :txt } + + it { is_expected.to include('backtrace', 'original exception') } + end + + context 'with xml response type format' do + subject { Grape::Xml.parse(last_response.body)['error'] } + + let(:response_format) { :xml } + + it { is_expected.to have_key('backtrace') & have_key('original-exception') } + end + end + + context 'when rescue validation errors include backtrace and original exception' do + let(:app) do + response_type = response_format + + Class.new(Grape::API) do + format response_type + + rescue_from Grape::Exceptions::ValidationErrors, backtrace: true, original_exception: true do |e| + error!(e, 418, {}, e.backtrace, e) + end + + params do + requires :weather + end + get '/forecast' do + 'sunny' + end + end + end + + before do + get '/forecast' + end + + context 'with json response type format' do + subject { JSON.parse(last_response.body) } + + let(:response_format) { :json } + + it 'does not include backtrace or original exception' do + expect(subject).to match([{ 'messages' => ['is missing'], 'params' => ['weather'] }]) + end + end + + context 'with txt response type format' do + subject { last_response.body } + + let(:response_format) { :txt } + + it { is_expected.to include('backtrace', 'original exception') } + end + + context 'with xml response type format' do + subject { Grape::Xml.parse(last_response.body)['error'] } + + let(:response_format) { :xml } + + it { is_expected.to have_key('backtrace') & have_key('original-exception') } + end + end + end + + describe '.content_type' do + it 'sets additional content-type' do + subject.content_type :xls, 'application/vnd.ms-excel' + subject.get :excel do + 'some binary content' + end + get '/excel.xls' + expect(last_response.content_type).to eq('application/vnd.ms-excel') + end + + it 'allows to override content-type' do + subject.get :content do + content_type 'text/javascript' + 'var x = 1;' + end + get '/content' + expect(last_response.content_type).to eq('text/javascript') + end + + it 'removes existing content types' do + subject.content_type :xls, 'application/vnd.ms-excel' + subject.get :excel do + 'some binary content' + end + get '/excel.json' + expect(last_response.status).to eq(406) + expect(last_response.body).to eq(Rack::Utils.escape_html("The requested format 'txt' is not supported.")) + end + end describe '.formatter' do context 'multiple formatters' do - before :each do + before do subject.formatter :json, ->(object, _env) { "{\"custom_formatter\":\"#{object[:some]}\"}" } subject.formatter :txt, ->(object, _env) { "custom_formatter: #{object[:some]}" } subject.get :simple do { some: 'hash' } end end + it 'sets one formatter' do get '/simple.json' expect(last_response.body).to eql '{"custom_formatter":"hash"}' end + it 'sets another formatter' do get '/simple.txt' expect(last_response.body).to eql 'custom_formatter: hash' end end + context 'custom formatter' do - before :each do + before do subject.content_type :json, 'application/json' subject.content_type :custom, 'application/custom' subject.formatter :custom, ->(object, _env) { "{\"custom_formatter\":\"#{object[:some]}\"}" } @@ -2537,35 +2879,41 @@ def self.call(message, _backtrace, _option, _env, _original_exception) { some: 'hash' } end end + it 'uses json' do get '/simple.json' expect(last_response.body).to eql '{"some":"hash"}' end + it 'uses custom formatter' do get '/simple.custom', 'HTTP_ACCEPT' => 'application/custom' expect(last_response.body).to eql '{"custom_formatter":"hash"}' end end + context 'custom formatter class' do - module ApiSpec - module CustomFormatter + let(:custom_formatter) do + Module.new do def self.call(object, _env) "{\"custom_formatter\":\"#{object[:some]}\"}" end end end - before :each do + + before do subject.content_type :json, 'application/json' subject.content_type :custom, 'application/custom' - subject.formatter :custom, ApiSpec::CustomFormatter + subject.formatter :custom, custom_formatter subject.get :simple do { some: 'hash' } end end + it 'uses json' do get '/simple.json' expect(last_response.body).to eql '{"some":"hash"}' end + it 'uses custom formatter' do get '/simple.custom', 'HTTP_ACCEPT' => 'application/custom' expect(last_response.body).to eql '{"custom_formatter":"hash"}' @@ -2580,11 +2928,12 @@ def self.call(object, _env) { x: params[:x] } end post '/data', '{"x":42}', 'CONTENT_TYPE' => 'application/json' - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq('{"x":42}') end + context 'lambda parser' do - before :each do + before do subject.content_type :txt, 'text/plain' subject.content_type :custom, 'text/custom' subject.parser :custom, ->(object, _env) { { object.to_sym => object.to_s.reverse } } @@ -2592,36 +2941,41 @@ def self.call(object, _env) params[:simple] end end + ['text/custom', 'text/custom; charset=UTF-8'].each do |content_type| it "uses parser for #{content_type}" do put '/simple', 'simple', 'CONTENT_TYPE' => content_type - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eql 'elpmis' end end end + context 'custom parser class' do - module ApiSpec - module CustomParser + let(:custom_parser) do + Module.new do def self.call(object, _env) { object.to_sym => object.to_s.reverse } end end end - before :each do + + before do subject.content_type :txt, 'text/plain' subject.content_type :custom, 'text/custom' - subject.parser :custom, ApiSpec::CustomParser + subject.parser :custom, custom_parser subject.put :simple do params[:simple] end end + it 'uses custom parser' do put '/simple', 'simple', 'CONTENT_TYPE' => 'text/custom' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eql 'elpmis' end end + if Object.const_defined? :MultiXml context 'multi_xml' do it "doesn't parse yaml" do @@ -2629,7 +2983,7 @@ def self.call(object, _env) params[:tag] end put '/yaml', 'a123', 'CONTENT_TYPE' => 'application/xml' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eql 'Disallowed type attribute: "symbol"' end end @@ -2639,46 +2993,50 @@ def self.call(object, _env) subject.put :yaml do params[:tag] end - put '/yaml', 'a123', 'CONTENT_TYPE' => 'application/xml' - expect(last_response.status).to eq(200) - expect(last_response.body).to eql '{"type"=>"symbol", "__content__"=>"a123"}' + body = 'a123' + put '/yaml', body, 'CONTENT_TYPE' => 'application/xml' + expect(last_response).to be_successful + expect(last_response.body).to eq(Grape::Xml.parse(body)['tag'].to_s) end end end context 'none parser class' do - before :each do + before do subject.parser :json, nil subject.put 'data' do - "body: #{env['api.request.body']}" + "body: #{env[Grape::Env::API_REQUEST_BODY]}" end end + it 'does not parse data' do put '/data', 'not valid json', 'CONTENT_TYPE' => 'application/json' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('body: not valid json') end end end describe '.default_format' do - before :each do + before do subject.format :json subject.default_format :json end + it 'returns data in default format' do subject.get '/data' do { x: 42 } end get '/data' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('{"x":42}') end + it 'parses data in default format' do subject.post '/data' do { x: params[:x] } end post '/data', '{"x":42}', 'CONTENT_TYPE' => '' - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq('{"x":42}') end end @@ -2691,16 +3049,18 @@ def self.call(object, _env) raise 'rain!' end get '/exception' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful end + it 'has a default error status' do subject.rescue_from :all subject.get '/exception' do raise 'rain!' end get '/exception' - expect(last_response.status).to eql 500 + expect(last_response).to be_server_error end + it 'uses the default error status in error!' do subject.rescue_from :all subject.default_error_status 400 @@ -2708,45 +3068,7 @@ def self.call(object, _env) error! 'rain!' end get '/exception' - expect(last_response.status).to eql 400 - end - end - - context 'http_codes' do - let(:error_presenter) do - Class.new(Grape::Entity) do - expose :code - expose :static - - def static - 'some static text' - end - end - end - - it 'is used as presenter' do - subject.desc 'some desc', http_codes: [ - [408, 'Unauthorized', error_presenter] - ] - - subject.get '/exception' do - error!({ code: 408 }, 408) - end - - get '/exception' - expect(last_response.status).to eql 408 - expect(last_response.body).to eql({ code: 408, static: 'some static text' }.to_json) - end - - it 'presented with' do - error = { code: 408, with: error_presenter }.freeze - subject.get '/exception' do - error! error, 408 - end - - get '/exception' - expect(last_response.status).to eql 408 - expect(last_response.body).to eql({ code: 408, static: 'some static text' }.to_json) + expect(last_response).to be_bad_request end end @@ -2756,22 +3078,25 @@ def static expect(subject.routes).to eq([]) end end + describe 'single method api structure' do - before(:each) do + before do subject.get :ping do 'pong' end end + it 'returns one route' do expect(subject.routes.size).to eq(1) route = subject.routes[0] expect(route.version).to be_nil expect(route.path).to eq('/ping(.:format)') - expect(route.request_method).to eq('GET') + expect(route.request_method).to eq(Rack::GET) end end + describe 'api structure with two versions and a namespace' do - before :each do + before do subject.version 'v1', using: :path subject.get 'version' do api.version @@ -2787,30 +3112,37 @@ def static end end end + it 'returns the latest version set' do expect(subject.version).to eq('v2') end + it 'returns versions' do expect(subject.versions).to eq(%w[v1 v2]) end + it 'sets route paths' do expect(subject.routes.size).to be >= 2 expect(subject.routes[0].path).to eq('/:version/version(.:format)') expect(subject.routes[1].path).to eq('/p/:version/n1/n2/version(.:format)') end + it 'sets route versions' do expect(subject.routes[0].version).to eq('v1') expect(subject.routes[1].version).to eq('v2') end + it 'sets a nested namespace' do expect(subject.routes[1].namespace).to eq('/n1/n2') end + it 'sets prefix' do expect(subject.routes[1].prefix).to eq('p') end end + describe 'api structure with additional parameters' do - before(:each) do + before do subject.params do requires :token, desc: 'a token' optional :limit, desc: 'the limit' @@ -2819,14 +3151,17 @@ def static params[:string].split(params[:token], (params[:limit] || 0).to_i) end end + it 'splits a string' do get '/split/a,b,c.json', token: ',' expect(last_response.body).to eq('["a","b","c"]') end + it 'splits a string with limit' do get '/split/a,b,c.json', token: ',', limit: '2' expect(last_response.body).to eq('["a","b,c"]') end + it 'sets params' do expect(subject.routes.map do |route| { params: route.params } @@ -2841,8 +3176,9 @@ def static ] end end + describe 'api structure with multiple apis' do - before(:each) do + before do subject.params do requires :one, desc: 'a token' optional :two, desc: 'the limit' @@ -2857,6 +3193,7 @@ def static subject.get 'two' do end end + it 'sets params' do expect(subject.routes.map do |route| { params: route.params } @@ -2876,8 +3213,9 @@ def static ] end end + describe 'api structure with an api without params' do - before(:each) do + before do subject.params do requires :one, desc: 'a token' optional :two, desc: 'the limit' @@ -2888,6 +3226,7 @@ def static subject.get 'two' do end end + it 'sets params' do expect(subject.routes.map do |route| { params: route.params } @@ -2904,17 +3243,20 @@ def static ] end end + describe 'api with a custom route setting' do - before(:each) do + before do subject.route_setting :custom, key: 'value' subject.get 'one' end + it 'exposed' do expect(subject.routes.count).to eq 1 route = subject.routes.first expect(route.settings[:custom]).to eq(key: 'value') end end + describe 'status' do it 'can be set to arbitrary Integer value' do subject.get '/foo' do @@ -2923,6 +3265,7 @@ def static get '/foo' expect(last_response.status).to eq 210 end + it 'can be set with a status code symbol' do subject.get '/foo' do status :see_other @@ -2937,20 +3280,22 @@ def static it 'empty array of routes' do expect(subject.routes).to eq([]) end + it 'empty array of routes' do subject.desc 'grape api' expect(subject.routes).to eq([]) end + it 'describes a method' do subject.desc 'first method' subject.get :first expect(subject.routes.length).to eq(1) route = subject.routes.first expect(route.description).to eq('first method') - expect(route.route_foo).to be_nil expect(route.params).to eq({}) - expect(route.options).to be_a_kind_of(Hash) + expect(route.options).to be_a(Hash) end + it 'has params which does not include format and version as named captures' do subject.version :v1, using: :path subject.get :first @@ -2958,6 +3303,7 @@ def static expect(param_keys).not_to include('format') expect(param_keys).not_to include('version') end + it 'describes methods separately' do subject.desc 'first method' subject.get :first @@ -2971,6 +3317,7 @@ def static { description: 'second method', params: {} } ] end + it 'resets desc' do subject.desc 'first method' subject.get :first @@ -2982,26 +3329,29 @@ def static { description: nil, params: {} } ] end + it 'namespaces and describe arbitrary parameters' do subject.namespace 'ns' do desc 'ns second', foo: 'bar' get 'second' end expect(subject.routes.map do |route| - { description: route.description, foo: route.route_foo, params: route.params } + { description: route.description, foo: route.options[:foo], params: route.params } end).to eq [ { description: 'ns second', foo: 'bar', params: {} } ] end - it 'includes details' do - subject.desc 'method', details: 'method details' + + it 'includes detail' do + subject.desc 'method', detail: 'method details' subject.get 'method' expect(subject.routes.map do |route| - { description: route.description, details: route.details, params: route.params } + { description: route.description, detail: route.detail, params: route.params } end).to eq [ - { description: 'method', details: 'method details', params: {} } + { description: 'method', detail: 'method details', params: {} } ] end + it 'describes a method with parameters' do subject.desc 'Reverses a string.', params: { 's' => { desc: 'string to reverse', type: 'string' } } subject.get 'reverse' do @@ -3013,6 +3363,7 @@ def static { description: 'Reverses a string.', params: { 's' => { desc: 'string to reverse', type: 'string' } } } ] end + it 'does not inherit param descriptions in consequent namespaces' do subject.desc 'global description' subject.params do @@ -3020,13 +3371,13 @@ def static optional :param2 end subject.namespace 'ns1' do - get { ; } + get {} end subject.params do optional :param2 end subject.namespace 'ns2' do - get { ; } + get {} end routes_doc = subject.routes.map do |route| { description: route.description, params: route.params } @@ -3043,6 +3394,7 @@ def static } } ] end + it 'merges the parameters of the namespace with the parameters of the method' do subject.desc 'namespace' subject.params do @@ -3067,6 +3419,7 @@ def static } } ] end + it 'merges the parameters of nested namespaces' do subject.desc 'ns1' subject.params do @@ -3099,6 +3452,7 @@ def static } } ] end + it 'groups nested params and prevents overwriting of params with same name in different groups' do subject.desc 'method' subject.params do @@ -3114,14 +3468,15 @@ def static subject.get 'method' expect(subject.routes.map(&:params)).to eq [{ - 'group1' => { required: true, type: 'Array' }, + 'group1' => { required: true, type: 'Array' }, 'group1[param1]' => { required: false, desc: 'group1 param1 desc' }, 'group1[param2]' => { required: true, desc: 'group1 param2 desc' }, - 'group2' => { required: true, type: 'Array' }, + 'group2' => { required: true, type: 'Array' }, 'group2[param1]' => { required: false, desc: 'group2 param1 desc' }, 'group2[param2]' => { required: true, desc: 'group2 param2 desc' } }] end + it 'uses full name of parameters in nested groups' do subject.desc 'nesting' subject.params do @@ -3142,13 +3497,21 @@ def static } } ] end + it 'allows to set the type attribute on :group element' do subject.params do group :foo, type: Array do optional :bar end end + subject.get 'method' + expect(subject.routes.map do |route| + { description: route.description, params: route.params } + end).to eq [ + { description: nil, params: { 'foo' => { required: true, type: 'Array' }, 'foo[bar]' => { required: false } } } + ] end + it 'parses parameters when no description is given' do subject.params do requires :one_param, desc: 'one param' @@ -3160,6 +3523,7 @@ def static { description: nil, params: { 'one_param' => { required: true, desc: 'one param' } } } ] end + it 'does not symbolize params' do subject.desc 'Reverses a string.', params: { 's' => { desc: 'string to reverse', type: 'string' } } subject.get 'reverse/:s' do @@ -3194,7 +3558,7 @@ def static it 'is able to cascade' do subject.mount lambda { |env| headers = {} - headers['X-Cascade'] == 'pass' unless env['PATH_INFO'].include?('boo') + headers['X-Cascade'] == 'pass' if env[Rack::PATH_INFO].exclude?('boo') [200, headers, ['Farfegnugen']] } => '/' @@ -3218,7 +3582,7 @@ def static subject.version 'v1', using: :path subject.namespace :cool do - app = Class.new(Grape::API) + app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass app.get('/awesome') do 'yo' end @@ -3234,12 +3598,12 @@ def static subject.version 'v1', using: :path subject.namespace :cool do - inner_app = Class.new(Grape::API) + inner_app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass inner_app.get('/awesome') do 'yo' end - app = Class.new(Grape::API) + app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass app.mount inner_app mount app end @@ -3251,10 +3615,10 @@ def static context 'when some rescues are defined by mounted' do it 'inherits parent rescues' do subject.rescue_from :all do |e| - rack_response("rescued from #{e.message}", 202) + error!("rescued from #{e.message}", 202) end - app = Class.new(Grape::API) + app = Class.new(described_class) subject.namespace :mounted do app.rescue_from ArgumentError @@ -3263,19 +3627,20 @@ def static end get '/mounted/fail' - expect(last_response.status).to eql 202 + expect(last_response).to be_accepted expect(last_response.body).to eq('rescued from doh!') end + it 'prefers rescues defined by mounted if they rescue similar error class' do subject.rescue_from StandardError do - rack_response('outer rescue') + error!('outer rescue') end - app = Class.new(Grape::API) + app = Class.new(described_class) subject.namespace :mounted do rescue_from StandardError do - rack_response('inner rescue') + error!('inner rescue') end app.get('/fail') { raise 'doh!' } mount app @@ -3284,16 +3649,17 @@ def static get '/mounted/fail' expect(last_response.body).to eq('inner rescue') end + it 'prefers rescues defined by mounted even if outer is more specific' do subject.rescue_from ArgumentError do - rack_response('outer rescue') + error!('outer rescue') end - app = Class.new(Grape::API) + app = Class.new(described_class) subject.namespace :mounted do rescue_from StandardError do - rack_response('inner rescue') + error!('inner rescue') end app.get('/fail') { raise ArgumentError.new } mount app @@ -3302,16 +3668,17 @@ def static get '/mounted/fail' expect(last_response.body).to eq('inner rescue') end + it 'prefers more specific rescues defined by mounted' do subject.rescue_from StandardError do - rack_response('outer rescue') + error!('outer rescue') end - app = Class.new(Grape::API) + app = Class.new(described_class) subject.namespace :mounted do rescue_from ArgumentError do - rack_response('inner rescue') + error!('inner rescue') end app.get('/fail') { raise ArgumentError.new } mount app @@ -3324,48 +3691,48 @@ def static it 'collects the routes of the mounted api' do subject.namespace :cool do - app = Class.new(Grape::API) + app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass app.get('/awesome') {} app.post('/sauce') {} mount app end expect(subject.routes.size).to eq(2) - expect(subject.routes.first.path).to match(%r{\/cool\/awesome}) - expect(subject.routes.last.path).to match(%r{\/cool\/sauce}) + expect(subject.routes.first.path).to match(%r{/cool/awesome}) + expect(subject.routes.last.path).to match(%r{/cool/sauce}) end it 'mounts on a path' do subject.namespace :cool do - app = Class.new(Grape::API) + app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass app.get '/awesome' do 'sauce' end mount app => '/mounted' end get '/mounted/cool/awesome' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('sauce') end it 'mounts on a nested path' do - APP1 = Class.new(Grape::API) - APP2 = Class.new(Grape::API) - APP2.get '/nice' do + app1 = Class.new(described_class) + app2 = Class.new(described_class) + app2.get '/nice' do 'play' end - # note that the reverse won't work, mount from outside-in - APP3 = subject - APP3.mount APP1 => '/app1' - APP1.mount APP2 => '/app2' + # NOTE: that the reverse won't work, mount from outside-in + app3 = subject + app3.mount app1 => '/app1' + app1.mount app2 => '/app2' get '/app1/app2/nice' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('play') options '/app1/app2/nice' - expect(last_response.status).to eq(204) + expect(last_response).to be_no_content end it 'responds to options' do - app = Class.new(Grape::API) + app = Class.new(described_class) app.get '/colour' do 'red' end @@ -3379,21 +3746,21 @@ def static end get '/apples/colour' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eq('red') options '/apples/colour' - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content get '/apples/pears/colour' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eq('green') options '/apples/pears/colour' - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content end it 'responds to options with path versioning' do subject.version 'v1', using: :path subject.namespace :apples do - app = Class.new(Grape::API) + app = Class.new(Grape::API) # rubocop:disable RSpec/DescribedClass app.get('/colour') do 'red' end @@ -3401,14 +3768,14 @@ def static end get '/v1/apples/colour' - expect(last_response.status).to eql 200 + expect(last_response).to be_successful expect(last_response.body).to eq('red') options '/v1/apples/colour' - expect(last_response.status).to eql 204 + expect(last_response).to be_no_content end it 'mounts a versioned API with nested resources' do - api = Class.new(Grape::API) do + api = Class.new(described_class) do version 'v1' resources :users do get :hello do @@ -3423,7 +3790,7 @@ def static end it 'mounts a prefixed API with nested resources' do - api = Class.new(Grape::API) do + api = Class.new(described_class) do prefix 'api' resources :users do get :hello do @@ -3438,7 +3805,7 @@ def static end it 'applies format to a mounted API with nested resources' do - api = Class.new(Grape::API) do + api = Class.new(described_class) do format :json resources :users do get do @@ -3453,7 +3820,7 @@ def static end it 'applies auth to a mounted API with nested resources' do - api = Class.new(Grape::API) do + api = Class.new(described_class) do format :json http_basic do |username, password| username == 'username' && password == 'password' @@ -3467,14 +3834,14 @@ def static subject.mount api get '/users' - expect(last_response.status).to eq(401) + expect(last_response).to be_unauthorized get '/users', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('username', 'password') expect(last_response.body).to eq({ users: true }.to_json) end it 'mounts multiple versioned APIs with nested resources' do - api1 = Class.new(Grape::API) do + api1 = Class.new(described_class) do version 'one', using: :header, vendor: 'test' resources :users do get :hello do @@ -3483,7 +3850,7 @@ def static end end - api2 = Class.new(Grape::API) do + api2 = Class.new(described_class) do version 'two', using: :header, vendor: 'test' resources :users do get :hello do @@ -3502,7 +3869,7 @@ def static end it 'recognizes potential versions with mounted path' do - a = Class.new(Grape::API) do + a = Class.new(described_class) do version :v1, using: :path get '/hello' do @@ -3510,7 +3877,7 @@ def static end end - b = Class.new(Grape::API) do + b = Class.new(described_class) do version :v1, using: :path get '/world' do @@ -3522,19 +3889,19 @@ def static subject.mount b => '/two' get '/one/v1/hello' - expect(last_response.status).to eq 200 + expect(last_response).to be_successful get '/two/v1/world' - expect(last_response.status).to eq 200 + expect(last_response).to be_successful end context 'when mounting class extends a subclass of Grape::API' do it 'mounts APIs with the same superclass' do - base_api = Class.new(Grape::API) + base_api = Class.new(described_class) a = Class.new(base_api) b = Class.new(base_api) - expect { a.mount b }.to_not raise_error + expect { a.mount b }.not_to raise_error end end @@ -3544,30 +3911,36 @@ def static def self.included(base) base.extend(ClassMethods) end - module ClassMethods + end + end + + before do + stub_const( + 'ClassMethods', + Module.new do def my_method @test = true end end - end + ) end - it 'should correctly include module in nested mount' do + it 'correctlies include module in nested mount' do module_to_include = included_module - v1 = Class.new(Grape::API) do + v1 = Class.new(described_class) do version :v1, using: :path include module_to_include my_method end - v2 = Class.new(Grape::API) do + v2 = Class.new(described_class) do version :v2, using: :path end - segment_base = Class.new(Grape::API) do + segment_base = Class.new(described_class) do mount v1 mount v2 end - Class.new(Grape::API) do + Class.new(described_class) do mount segment_base end @@ -3589,7 +3962,7 @@ def my_method it 'sets the instance' do expect(subject.instance).to be_nil subject.compile - expect(subject.instance).to be_kind_of(subject.base_instance) + expect(subject.instance).to be_a(subject.base_instance) end end @@ -3602,7 +3975,7 @@ def my_method end describe '.endpoint' do - before(:each) do + before do subject.format :json subject.get '/endpoint/options' do { @@ -3611,9 +3984,10 @@ def my_method } end end + it 'path' do get '/endpoint/options' - options = ::Grape::Json.load(last_response.body) + options = Grape::Json.load(last_response.body) expect(options['path']).to eq(['/endpoint/options']) expect(options['source_location'][0]).to include 'api_spec.rb' expect(options['source_location'][1].to_i).to be > 0 @@ -3622,7 +3996,7 @@ def my_method describe '.route' do context 'plain' do - before(:each) do + before do subject.get '/' do route.path end @@ -3630,6 +4004,7 @@ def my_method route.path end end + it 'provides access to route info' do get '/' expect(last_response.body).to eq('/(.:format)') @@ -3637,8 +4012,9 @@ def my_method expect(last_response.body).to eq('/path(.:format)') end end + context 'with desc' do - before(:each) do + before do subject.desc 'returns description' subject.get '/description' do route.description @@ -3648,114 +4024,136 @@ def my_method route.params[params[:id]] end end + it 'returns route description' do get '/description' expect(last_response.body).to eq('returns description') end + it 'returns route parameters' do get '/params/x' expect(last_response.body).to eq('y') end end end + describe '.format' do context ':txt' do - before(:each) do + before do subject.format :txt subject.content_type :json, 'application/json' subject.get '/meaning_of_life' do { meaning_of_life: 42 } end end + it 'forces txt without an extension' do get '/meaning_of_life' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end + it 'does not force txt with an extension' do get '/meaning_of_life.json' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_json) end + it 'forces txt from a non-accepting header' do get '/meaning_of_life', {}, 'HTTP_ACCEPT' => 'application/json' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end end + context ':txt only' do - before(:each) do + before do subject.format :txt subject.get '/meaning_of_life' do { meaning_of_life: 42 } end end + it 'forces txt without an extension' do get '/meaning_of_life' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end + it 'accepts specified extension' do get '/meaning_of_life.txt' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end + it 'does not accept extensions other than specified' do get '/meaning_of_life.json' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found end + it 'forces txt from a non-accepting header' do get '/meaning_of_life', {}, 'HTTP_ACCEPT' => 'application/json' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end end + context ':json' do - before(:each) do + before do subject.format :json subject.content_type :txt, 'text/plain' subject.get '/meaning_of_life' do { meaning_of_life: 42 } end end + it 'forces json without an extension' do get '/meaning_of_life' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_json) end + it 'does not force json with an extension' do get '/meaning_of_life.txt' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end + it 'forces json from a non-accepting header' do get '/meaning_of_life', {}, 'HTTP_ACCEPT' => 'text/html' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_json) end - it 'can be overwritten with an explicit content type' do + + it 'can be overwritten with an explicit api_format' do subject.get '/meaning_of_life_with_content_type' do - content_type 'text/plain' + api_format :txt { meaning_of_life: 42 }.to_s end get '/meaning_of_life_with_content_type' expect(last_response.body).to eq({ meaning_of_life: 42 }.to_s) end + it 'raised :error from middleware' do middleware = Class.new(Grape::Middleware::Base) do def before - throw :error, message: 'Unauthorized', status: 42 + throw :error, message: 'Unauthorized', status: 500 end end subject.use middleware subject.get do end get '/' - expect(last_response.status).to eq(42) + expect(last_response).to be_server_error expect(last_response.body).to eq({ error: 'Unauthorized' }.to_json) end end + context ':serializable_hash' do - class SerializableHashExample - def serializable_hash - { abc: 'def' } - end - end + before do + stub_const( + 'SerializableHashExample', + Class.new do + def serializable_hash + { abc: 'def' } + end + end + ) - before(:each) do subject.format :serializable_hash end + it 'instance' do subject.get '/example' do SerializableHashExample.new @@ -3763,6 +4161,7 @@ def serializable_hash get '/example' expect(last_response.body).to eq('{"abc":"def"}') end + it 'root' do subject.get '/example' do { 'root' => SerializableHashExample.new } @@ -3770,6 +4169,7 @@ def serializable_hash get '/example' expect(last_response.body).to eq('{"root":{"abc":"def"}}') end + it 'array' do subject.get '/examples' do [SerializableHashExample.new, SerializableHashExample.new] @@ -3778,23 +4178,26 @@ def serializable_hash expect(last_response.body).to eq('[{"abc":"def"},{"abc":"def"}]') end end + context ':xml' do - before(:each) do + before do subject.format :xml end + it 'string' do subject.get '/example' do 'example' end get '/example' - expect(last_response.status).to eq(500) - expect(last_response.body).to eq <<-XML - - - cannot convert String to xml - -XML + expect(last_response).to be_server_error + expect(last_response.body).to eq <<~XML + + + cannot convert String to xml + + XML end + it 'hash' do subject.get '/example' do { @@ -3803,46 +4206,48 @@ def serializable_hash } end get '/example' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq <<-XML - - - example1 - example2 - -XML + expect(last_response).to be_successful + expect(last_response.body).to eq <<~XML + + + example1 + example2 + + XML end + it 'array' do subject.get '/example' do %w[example1 example2] end get '/example' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq <<-XML - - - example1 - example2 - -XML + expect(last_response).to be_successful + expect(last_response.body).to eq <<~XML + + + example1 + example2 + + XML end + it 'raised :error from middleware' do middleware = Class.new(Grape::Middleware::Base) do def before - throw :error, message: 'Unauthorized', status: 42 + throw :error, message: 'Unauthorized', status: 500 end end subject.use middleware subject.get do end get '/' - expect(last_response.status).to eq(42) - expect(last_response.body).to eq <<-XML - - - Unauthorized - -XML + expect(last_response.status).to eq(500) + expect(last_response.body).to eq <<~XML + + + Unauthorized + + XML end end end @@ -3887,12 +4292,12 @@ def before context 'catch-all' do before do - api1 = Class.new(Grape::API) + api1 = Class.new(described_class) api1.version 'v1', using: :path api1.get 'hello' do 'v1' end - api2 = Class.new(Grape::API) + api2 = Class.new(described_class) api2.version 'v2', using: :path api2.get 'hello' do 'v2' @@ -3900,25 +4305,26 @@ def before subject.mount api1 subject.mount api2 end + [true, false].each do |anchor| it "anchor=#{anchor}" do subject.route :any, '*path', anchor: anchor do - error!("Unrecognized request path: #{params[:path]} - #{env['PATH_INFO']}#{env['SCRIPT_NAME']}", 404) + error!("Unrecognized request path: #{params[:path]} - #{env[Rack::PATH_INFO]}#{env[Rack::SCRIPT_NAME]}", 404) end get '/v1/hello' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('v1') get '/v2/hello' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('v2') options '/v2/hello' - expect(last_response.status).to eq(204) + expect(last_response).to be_no_content expect(last_response.body).to be_blank head '/v2/hello' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to be_blank get '/foobar' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found expect(last_response.body).to eq('Unrecognized request path: foobar - /foobar') end end @@ -3929,27 +4335,30 @@ def before it 'cascades' do subject.version 'v1', using: :path, cascade: true get '/v1/hello' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found expect(last_response.headers['X-Cascade']).to eq('pass') end + it 'does not cascade' do subject.version 'v2', using: :path, cascade: false get '/v2/hello' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found expect(last_response.headers.keys).not_to include 'X-Cascade' end end + context 'via endpoint' do it 'cascades' do subject.cascade true get '/hello' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found expect(last_response.headers['X-Cascade']).to eq('pass') end + it 'does not cascade' do subject.cascade false get '/hello' - expect(last_response.status).to eq(404) + expect(last_response).to be_not_found expect(last_response.headers.keys).not_to include 'X-Cascade' end end @@ -3964,11 +4373,7 @@ def before end get '/something' expect(last_response.status).to eq(406) - if ActiveSupport::VERSION::MAJOR == 3 - expect(last_response.body).to eq('{"error":"The requested format 'txt' is not supported."}') - else - expect(last_response.body).to eq('{"error":"The requested format 'txt' is not supported."}') - end + expect(last_response.body).to eq(Rack::Utils.escape_html({ error: "The requested format 'txt' is not supported." }.to_json)) end end @@ -3980,11 +4385,21 @@ def before end get '/something?format=' expect(last_response.status).to eq(406) - if ActiveSupport::VERSION::MAJOR == 3 - expect(last_response.body).to eq('The requested format '<script>blah</script>' is not supported.') - else - expect(last_response.body).to eq('The requested format '<script>blah</script>' is not supported.') + expect(last_response.body).to eq(Rack::Utils.escape_html("The requested format '' is not supported.")) + end + end + + context 'with non-UTF-8 characters in specified format' do + it 'converts the characters' do + subject.format :json + subject.content_type :json, 'application/json' + subject.get '/something' do + 'foo' end + get '/something?format=%0A%0B%BF' + expect(last_response.status).to eq(406) + message = "The requested format '\n\u000b\357\277\275' is not supported." + expect(last_response.body).to eq({ error: message }.to_json) end end @@ -3995,12 +4410,14 @@ def before body false end end + it 'returns blank body' do get '/blank' - expect(last_response.status).to eq(204) + expect(last_response).to be_no_content expect(last_response.body).to be_blank end end + context 'plain text' do before do subject.get '/text' do @@ -4009,16 +4426,17 @@ def before 'ignored' end end + it 'returns blank body' do get '/text' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq 'Hello World' end end end describe 'normal class methods' do - subject(:grape_api) { Class.new(Grape::API) } + subject(:grape_api) { Class.new(described_class) } before do stub_const('MyAPI', grape_api) @@ -4035,10 +4453,67 @@ def before end end + describe '.inherited' do + context 'overriding within class' do + let(:root_api) do + Class.new(described_class) do + @bar = 'Hello, world' + + def self.inherited(child_api) + super + child_api.instance_variable_set(:@foo, @bar.dup) + end + end + end + + let(:child_api) { Class.new(root_api) } + + it 'allows overriding the hook' do + expect(child_api.instance_variable_get(:@foo)).to eq('Hello, world') + end + end + + it 'does not override methods inherited from Class' do + Class.define_method(:test_method) {} + subclass = Class.new(described_class) + expect(subclass).not_to receive(:add_setup) + subclass.test_method + ensure + Class.remove_method(:test_method) + end + + context 'overriding via composition' do + let(:inherited) do + Module.new do + def inherited(api) + super + api.instance_variable_set(:@foo, @bar.dup) + end + end + end + + let(:root_api) do + context = self + + Class.new(described_class) do + @bar = 'Hello, world' + extend context.inherited + end + end + + let(:child_api) { Class.new(root_api) } + + it 'allows overriding the hook' do + expect(child_api.instance_variable_get(:@foo)).to eq('Hello, world') + end + end + end + describe 'const_missing' do - subject(:grape_api) { Class.new(Grape::API) } + subject(:grape_api) { Class.new(described_class) } + let(:mounted) do - Class.new(Grape::API) do + Class.new(described_class) do get '/missing' do SomeRandomConstant end @@ -4051,4 +4526,216 @@ def before expect { get '/const/missing' }.to raise_error(NameError).with_message(/SomeRandomConstant/) end end + + describe 'custom route helpers on nested APIs' do + subject(:grape_api) do + Class.new(described_class) do + version 'v1', using: :path + end + end + + let(:shared_api_module) do + Module.new do + # rubocop:disable Style/ExplicitBlockArgument because this causes + # the underlying issue in this form + def uniqe_id_route + params do + use :unique_id + end + route_param(:id) do + yield + end + end + # rubocop:enable Style/ExplicitBlockArgument + end + end + let(:shared_api_definitions) do + Module.new do + extend ActiveSupport::Concern + + included do + helpers do + params :unique_id do + requires :id, type: String, + allow_blank: false, + regexp: /\d+-\d+/ + end + end + end + end + end + let(:orders_root) do + shared = shared_api_definitions + find = orders_find_endpoint + Class.new(described_class) do + include shared + + namespace(:orders) do + mount find + end + end + end + let(:orders_find_endpoint) do + shared = shared_api_definitions + Class.new(described_class) do + include shared + + uniqe_id_route do + desc 'Fetch a single order' do + detail 'While specifying the order id on the route' + end + get { params[:id] } + end + end + end + + before do + Grape::API::Instance.extend(shared_api_module) + subject.mount orders_root + end + + it 'returns an error when the id is bad' do + get '/v1/orders/abc' + expect(last_response.body).to eq('id is invalid') + end + + it 'returns the given id when it is valid' do + get '/v1/orders/1-2' + expect(last_response.body).to eq('1-2') + end + end + + context 'instance variables' do + context 'when setting instance variables in a before validation' do + it 'is accessible inside the endpoint' do + expected_instance_variable_value = 'wadus' + + subject.before do + @my_var = expected_instance_variable_value + end + + subject.get('/') do + { my_var: @my_var }.to_json + end + + get '/' + expect(last_response.body).to eq({ my_var: expected_instance_variable_value }.to_json) + end + end + + context 'when setting instance variables inside the endpoint code' do + it 'is accessible inside the rescue_from handler' do + expected_instance_variable_value = 'wadus' + + subject.rescue_from(:all) do + body = { my_var: @my_var } + error!(body, 400) + end + + subject.get('/') do + @my_var = expected_instance_variable_value + raise + end + + get '/' + expect(last_response).to be_bad_request + expect(last_response.body).to eq({ my_var: expected_instance_variable_value }.to_json) + end + + it 'is NOT available in other endpoints of the same api' do + expected_instance_variable_value = 'wadus' + + subject.get('/first') do + @my_var = expected_instance_variable_value + { my_var: @my_var }.to_json + end + + subject.get('/second') do + { my_var: @my_var }.to_json + end + + get '/first' + expect(last_response.body).to eq({ my_var: expected_instance_variable_value }.to_json) + get '/second' + expect(last_response.body).to eq({ my_var: nil }.to_json) + end + end + + context 'when set type to a route_param' do + context 'and the param does not match' do + it 'returns a 404 response' do + subject.namespace :books do + route_param :id, type: Integer do + get do + params[:id] + end + end + end + + get '/books/other' + expect(last_response).to be_not_found + end + end + end + end + + context 'rack_response deprecated' do + let(:app) do + Class.new(described_class) do + rescue_from :all do + rack_response('deprecated', 500, 'Content-Type' => 'text/plain') + end + + get 'test' do + raise ArgumentError + end + end + end + + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with('The rack_response method has been deprecated, use error! instead.') + get 'test' + expect(last_response.body).to eq('deprecated') + end + end + + context 'rescue_from context' do + subject { last_response } + + let(:api) do + Class.new(described_class) do + rescue_from :all do + error!(context.env, 400) + end + get { raise ArgumentError, 'Oops!' } + end + end + + let(:app) { api } + + before { get '/' } + + it { is_expected.to be_bad_request } + end + + describe '.build_with' do + let(:app) do + Class.new(described_class) do + build_with :unknown + params do + requires :a_param, type: Integer + end + get + end + end + + before do + get '/' + end + + it 'raises an UnknownParamsBuilder error' do + expect(last_response).to be_server_error + expect(last_response.body).to eq('unknown params_builder: unknown') + end + end end diff --git a/spec/grape/config_spec.rb b/spec/grape/config_spec.rb deleted file mode 100644 index 07bed04a3..000000000 --- a/spec/grape/config_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe '.configure' do - before do - Grape.configure do |config| - config.param_builder = 42 - end - end - - after do - Grape.config.reset - end - - it 'is configured to the new value' do - expect(Grape.config.param_builder).to eq 42 - end -end diff --git a/spec/grape/content_types_spec.rb b/spec/grape/content_types_spec.rb new file mode 100644 index 000000000..19040bbeb --- /dev/null +++ b/spec/grape/content_types_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +describe Grape::ContentTypes do + describe 'DEFAULTS' do + subject { described_class::DEFAULTS } + + let(:expected_value) do + { + xml: 'application/xml', + serializable_hash: 'application/json', + json: 'application/json', + binary: 'application/octet-stream', + txt: 'text/plain' + }.freeze + end + + it { is_expected.to eq(expected_value) } + end + + describe 'MIME_TYPES' do + subject { described_class::MIME_TYPES } + + let(:expected_value) do + { + 'application/xml' => :xml, + 'application/json' => :json, + 'application/octet-stream' => :binary, + 'text/plain' => :txt + }.freeze + end + + it { is_expected.to eq(expected_value) } + end + + describe '.content_types_for' do + subject { described_class.content_types_for(from_settings) } + + context 'when from_settings is present' do + let(:from_settings) { { a: :b } } + + it { is_expected.to eq(from_settings) } + end + + context 'when from_settings is not present' do + let(:from_settings) { nil } + + it { is_expected.to be(described_class::DEFAULTS) } + end + end + + describe '.mime_types_for' do + subject { described_class.mime_types_for(from_settings) } + + context 'when from_settings is equal to Grape::ContentTypes::DEFAULTS' do + let(:from_settings) do + { + xml: 'application/xml', + serializable_hash: 'application/json', + json: 'application/json', + binary: 'application/octet-stream', + txt: 'text/plain' + }.freeze + end + + it { is_expected.to be(described_class::MIME_TYPES) } + end + + context 'when from_settings is not equal to Grape::ContentTypes::DEFAULTS' do + let(:from_settings) do + { + xml: 'application/xml;charset=utf-8' + } + end + + it { is_expected.to eq('application/xml' => :xml) } + end + end +end diff --git a/spec/grape/dsl/callbacks_spec.rb b/spec/grape/dsl/callbacks_spec.rb index 73dbc259e..7fc444fe2 100644 --- a/spec/grape/dsl/callbacks_spec.rb +++ b/spec/grape/dsl/callbacks_spec.rb @@ -1,46 +1,41 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Callbacks do + subject { dummy_class } -module Grape - module DSL - module CallbacksSpec - class Dummy - include Grape::DSL::Callbacks - end + let(:dummy_class) do + Class.new do + include Grape::DSL::Callbacks end + end - describe Callbacks do - subject { Class.new(CallbacksSpec::Dummy) } - let(:proc) { ->() {} } + let(:proc) { -> {} } - describe '.before' do - it 'adds a block to "before"' do - expect(subject).to receive(:namespace_stackable).with(:befores, proc) - subject.before(&proc) - end - end + describe '.before' do + it 'adds a block to "before"' do + expect(subject).to receive(:namespace_stackable).with(:befores, proc) + subject.before(&proc) + end + end - describe '.before_validation' do - it 'adds a block to "before_validation"' do - expect(subject).to receive(:namespace_stackable).with(:before_validations, proc) - subject.before_validation(&proc) - end - end + describe '.before_validation' do + it 'adds a block to "before_validation"' do + expect(subject).to receive(:namespace_stackable).with(:before_validations, proc) + subject.before_validation(&proc) + end + end - describe '.after_validation' do - it 'adds a block to "after_validation"' do - expect(subject).to receive(:namespace_stackable).with(:after_validations, proc) - subject.after_validation(&proc) - end - end + describe '.after_validation' do + it 'adds a block to "after_validation"' do + expect(subject).to receive(:namespace_stackable).with(:after_validations, proc) + subject.after_validation(&proc) + end + end - describe '.after' do - it 'adds a block to "after"' do - expect(subject).to receive(:namespace_stackable).with(:afters, proc) - subject.after(&proc) - end - end + describe '.after' do + it 'adds a block to "after"' do + expect(subject).to receive(:namespace_stackable).with(:afters, proc) + subject.after(&proc) end end end diff --git a/spec/grape/dsl/configuration_spec.rb b/spec/grape/dsl/configuration_spec.rb deleted file mode 100644 index 32b015f75..000000000 --- a/spec/grape/dsl/configuration_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Grape - module DSL - module ConfigurationSpec - class Dummy - include Grape::DSL::Configuration - end - end - describe Configuration do - subject { Class.new(ConfigurationSpec::Dummy) } - end - end -end diff --git a/spec/grape/dsl/desc_spec.rb b/spec/grape/dsl/desc_spec.rb index 9822620c0..aa2aa4c33 100644 --- a/spec/grape/dsl/desc_spec.rb +++ b/spec/grape/dsl/desc_spec.rb @@ -1,101 +1,85 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Desc do + subject { dummy_class } -module Grape - module DSL - module DescSpec - class Dummy - extend Grape::DSL::Desc - end + let(:dummy_class) do + Class.new do + extend Grape::DSL::Desc end - describe Desc do - subject { Class.new(DescSpec::Dummy) } + end - describe '.desc' do - it 'sets a description' do - desc_text = 'The description' - options = { message: 'none' } - subject.desc desc_text, options - expect(subject.namespace_setting(:description)).to eq(options.merge(description: desc_text)) - expect(subject.route_setting(:description)).to eq(options.merge(description: desc_text)) - end + describe '.desc' do + it 'sets a description' do + desc_text = 'The description' + options = { message: 'none' } + subject.desc desc_text, options + expect(subject.namespace_setting(:description)).to eq(options.merge(description: desc_text)) + expect(subject.route_setting(:description)).to eq(options.merge(description: desc_text)) + end - it 'can be set with a block' do - expected_options = { - summary: 'summary', - description: 'The description', - detail: 'more details', - params: { first: :param }, - entity: Object, - http_codes: [[401, 'Unauthorized', 'Entities::Error']], - named: 'My named route', - body_name: 'My body name', - headers: [ - XAuthToken: { - description: 'Valdates your identity', - required: true - }, - XOptionalHeader: { - description: 'Not really needed', - required: false - } - ], - hidden: false, - deprecated: false, - is_array: true, - nickname: 'nickname', - produces: %w[array of mime_types], - consumes: %w[array of mime_types], - tags: %w[tag1 tag2], - security: %w[array of security schemes] + it 'can be set with a block' do + expected_options = { + summary: 'summary', + description: 'The description', + detail: 'more details', + params: { first: :param }, + entity: Object, + default: { code: 400, message: 'Invalid' }, + http_codes: [[401, 'Unauthorized', 'Entities::Error']], + named: 'My named route', + body_name: 'My body name', + headers: [ + XAuthToken: { + description: 'Valdates your identity', + required: true + }, + XOptionalHeader: { + description: 'Not really needed', + required: false } + ], + hidden: false, + deprecated: false, + is_array: true, + nickname: 'nickname', + produces: %w[array of mime_types], + consumes: %w[array of mime_types], + tags: %w[tag1 tag2], + security: %w[array of security schemes] + } - subject.desc 'The description' do - summary 'summary' - detail 'more details' - params(first: :param) - success Object - failure [[401, 'Unauthorized', 'Entities::Error']] - named 'My named route' - body_name 'My body name' - headers [ - XAuthToken: { - description: 'Valdates your identity', - required: true - }, - XOptionalHeader: { - description: 'Not really needed', - required: false - } - ] - hidden false - deprecated false - is_array true - nickname 'nickname' - produces %w[array of mime_types] - consumes %w[array of mime_types] - tags %w[tag1 tag2] - security %w[array of security schemes] - end - - expect(subject.namespace_setting(:description)).to eq(expected_options) - expect(subject.route_setting(:description)).to eq(expected_options) - end - - it 'can be set with options and a block' do - expect(subject).to receive(:warn).with('[DEPRECATION] Passing a options hash and a block to `desc` is deprecated. Move all hash options to block.') - - desc_text = 'The description' - detail_text = 'more details' - options = { message: 'none' } - subject.desc desc_text, options do - detail detail_text - end - expect(subject.namespace_setting(:description)).to eq(description: desc_text, detail: detail_text) - expect(subject.route_setting(:description)).to eq(description: desc_text, detail: detail_text) - end + subject.desc 'The description' do + summary 'summary' + detail 'more details' + params(first: :param) + success Object + default code: 400, message: 'Invalid' + failure [[401, 'Unauthorized', 'Entities::Error']] + named 'My named route' + body_name 'My body name' + headers [ + XAuthToken: { + description: 'Valdates your identity', + required: true + }, + XOptionalHeader: { + description: 'Not really needed', + required: false + } + ] + hidden false + deprecated false + is_array true + nickname 'nickname' + produces %w[array of mime_types] + consumes %w[array of mime_types] + tags %w[tag1 tag2] + security %w[array of security schemes] end + + expect(subject.namespace_setting(:description)).to eq(expected_options) + expect(subject.route_setting(:description)).to eq(expected_options) end end end diff --git a/spec/grape/dsl/headers_spec.rb b/spec/grape/dsl/headers_spec.rb index d23652d07..1502176bd 100644 --- a/spec/grape/dsl/headers_spec.rb +++ b/spec/grape/dsl/headers_spec.rb @@ -1,34 +1,59 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Headers do + subject { dummy_class.new } -module Grape - module DSL - module HeadersSpec - class Dummy - include Grape::DSL::Headers - end + let(:dummy_class) do + Class.new do + include Grape::DSL::Headers end - describe Headers do - subject { HeadersSpec::Dummy.new } - - describe '#header' do - describe 'set' do - before do - subject.header 'Name', 'Value' - end - - it 'returns value' do - expect(subject.header['Name']).to eq 'Value' - expect(subject.header('Name')).to eq 'Value' - end + end + + let(:header_data) do + { 'first key' => 'First Value', + 'second key' => 'Second Value' } + end + + context 'when headers are set' do + describe '#header' do + before do + header_data.each { |k, v| subject.header(k, v) } + end + + describe 'get' do + it 'returns a specifc value' do + expect(subject.header['first key']).to eq 'First Value' + expect(subject.header['second key']).to eq 'Second Value' end - it 'returns nil' do - expect(subject.header['Name']).to be nil - expect(subject.header('Name')).to be nil + it 'returns all set headers' do + expect(subject.header).to eq header_data + expect(subject.headers).to eq header_data end end + + describe 'set' do + it 'returns value' do + expect(subject.header('third key', 'Third Value')) + expect(subject.header['third key']).to eq 'Third Value' + end + end + + describe 'delete' do + it 'deletes a header key-value pair' do + expect(subject.header('first key')).to eq header_data['first key'] + expect(subject.header).not_to have_key('first key') + end + end + end + end + + context 'when no headers are set' do + describe '#header' do + it 'returns nil' do + expect(subject.header['first key']).to be_nil + expect(subject.header('first key')).to be_nil + end end end end diff --git a/spec/grape/dsl/helpers_spec.rb b/spec/grape/dsl/helpers_spec.rb index 2f7bbf5a1..1c4d539ee 100644 --- a/spec/grape/dsl/helpers_spec.rb +++ b/spec/grape/dsl/helpers_spec.rb @@ -1,101 +1,103 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Helpers do + subject { dummy_class } -module Grape - module DSL - module HelpersSpec - class Dummy - include Grape::DSL::Helpers + let(:dummy_class) do + Class.new do + include Grape::DSL::Helpers - def self.mods - namespace_stackable(:helpers) - end - - def self.first_mod - mods.first - end + def self.mods + namespace_stackable(:helpers) end - end - - module BooleanParam - extend Grape::API::Helpers - params :requires_toggle_prm do - requires :toggle_prm, type: Boolean + def self.first_mod + mods.first end end + end - class Base < Grape::API - helpers BooleanParam + let(:proc) do + lambda do |*| + def test + :test + end end + end - class Child < Base; end + describe '.helpers' do + it 'adds a module with the given block' do + expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original + expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original + subject.helpers(&proc) - describe Helpers do - subject { Class.new(HelpersSpec::Dummy) } - let(:proc) do - lambda do |*| - def test - :test - end - end - end + expect(subject.first_mod.instance_methods).to include(:test) + end - describe '.helpers' do - it 'adds a module with the given block' do - expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original - expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original - subject.helpers(&proc) + it 'uses provided modules' do + mod = Module.new - expect(subject.first_mod.instance_methods).to include(:test) - end + expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original.twice + expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original + subject.helpers(mod, &proc) - it 'uses provided modules' do - mod = Module.new + expect(subject.first_mod).to eq mod + end - expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original.exactly(2).times - expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original - subject.helpers(mod, &proc) + it 'uses many provided modules' do + mod = Module.new + mod2 = Module.new + mod3 = Module.new - expect(subject.first_mod).to eq mod - end + expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original.exactly(4).times + expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original.exactly(3).times - it 'uses many provided modules' do - mod = Module.new - mod2 = Module.new - mod3 = Module.new + subject.helpers(mod, mod2, mod3, &proc) - expect(subject).to receive(:namespace_stackable).with(:helpers, kind_of(Grape::DSL::Helpers::BaseHelper)).and_call_original.exactly(4).times - expect(subject).to receive(:namespace_stackable).with(:helpers).and_call_original.exactly(3).times + expect(subject.mods).to include(mod) + expect(subject.mods).to include(mod2) + expect(subject.mods).to include(mod3) + end - subject.helpers(mod, mod2, mod3, &proc) + context 'with an external file' do + let(:boolean_helper) do + Module.new do + extend Grape::API::Helpers - expect(subject.mods).to include(mod) - expect(subject.mods).to include(mod2) - expect(subject.mods).to include(mod3) + params :requires_toggle_prm do + requires :toggle_prm, type: Boolean + end end + end - context 'with an external file' do - it 'sets Boolean as a Grape::API::Boolean' do - subject.helpers BooleanParam - expect(subject.first_mod::Boolean).to eq Grape::API::Boolean + it 'sets Boolean as a Grape::API::Boolean' do + subject.helpers boolean_helper + expect(subject.first_mod::Boolean).to eq Grape::API::Boolean + end + end + + context 'in child classes' do + let(:base_class) do + Class.new(Grape::API) do + helpers do + params :requires_toggle_prm do + requires :toggle_prm, type: Integer + end end end + end - context 'in child classes' do - it 'is available' do - klass = Child - expect do - klass.instance_eval do - params do - use :requires_toggle_prm - end - end - end.to_not raise_exception + let(:api_class) do + Class.new(base_class) do + params do + use :requires_toggle_prm end end end + + it 'is available' do + expect { api_class }.not_to raise_exception + end end end end diff --git a/spec/grape/dsl/inside_route_spec.rb b/spec/grape/dsl/inside_route_spec.rb index 9d83c148d..5e5b8b5d0 100644 --- a/spec/grape/dsl/inside_route_spec.rb +++ b/spec/grape/dsl/inside_route_spec.rb @@ -1,35 +1,29 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::InsideRoute do + subject { dummy_class.new } -module Grape - module DSL - module InsideRouteSpec - class Dummy - include Grape::DSL::InsideRoute + let(:dummy_class) do + Class.new do + include Grape::DSL::InsideRoute - attr_reader :env, :request, :new_settings + attr_reader :env, :request, :new_settings - def initialize - @env = {} - @header = {} - @new_settings = { namespace_inheritable: {}, namespace_stackable: {} } - end + def initialize + @env = {} + @header = {} + @new_settings = { namespace_inheritable: {}, namespace_stackable: {} } end end end -end - -describe Grape::Endpoint do - subject { Grape::DSL::InsideRouteSpec::Dummy.new } describe '#version' do it 'defaults to nil' do - expect(subject.version).to be nil + expect(subject.version).to be_nil end it 'returns env[api.version]' do - subject.env['api.version'] = 'dummy' + subject.env[Grape::Env::API_VERSION] = 'dummy' expect(subject.version).to eq 'dummy' end end @@ -43,6 +37,7 @@ def initialize before do catch(:error) { subject.error! 'Not Found', 404 } end + it 'sets status' do expect(subject.status).to eq 404 end @@ -53,6 +48,7 @@ def initialize subject.namespace_inheritable(:default_error_status, 500) catch(:error) { subject.error! 'Unknown' } end + it 'sets status to default_error_status' do expect(subject.status).to eq 500 end @@ -96,27 +92,27 @@ def initialize %w[GET PUT OPTIONS].each do |method| it 'defaults to 200 on GET' do request = Grape::Request.new(Rack::MockRequest.env_for('/', method: method)) - expect(subject).to receive(:request).and_return(request) + expect(subject).to receive(:request).and_return(request).twice expect(subject.status).to eq 200 end end it 'defaults to 201 on POST' do - request = Grape::Request.new(Rack::MockRequest.env_for('/', method: 'POST')) + request = Grape::Request.new(Rack::MockRequest.env_for('/', method: Rack::POST)) expect(subject).to receive(:request).and_return(request) expect(subject.status).to eq 201 end it 'defaults to 204 on DELETE' do - request = Grape::Request.new(Rack::MockRequest.env_for('/', method: 'DELETE')) - expect(subject).to receive(:request).and_return(request) + request = Grape::Request.new(Rack::MockRequest.env_for('/', method: Rack::DELETE)) + expect(subject).to receive(:request).and_return(request).twice expect(subject.status).to eq 204 end it 'defaults to 200 on DELETE with a body present' do - request = Grape::Request.new(Rack::MockRequest.env_for('/', method: 'DELETE')) + request = Grape::Request.new(Rack::MockRequest.env_for('/', method: Rack::DELETE)) subject.body 'content here' - expect(subject).to receive(:request).and_return(request) + expect(subject).to receive(:request).and_return(request).twice expect(subject.status).to eq 200 end @@ -136,7 +132,7 @@ def initialize end it 'accepts unknown Integer status codes' do - expect { subject.status 210 }.to_not raise_error + expect { subject.status 210 }.not_to raise_error end it 'raises error if status is not a integer or symbol' do @@ -165,13 +161,7 @@ def initialize end it 'returns default' do - expect(subject.content_type).to be nil - end - end - - describe '#cookies' do - it 'returns an instance of Cookies' do - expect(subject.cookies).to be_a Grape::Cookies + expect(subject.content_type).to be_nil end end @@ -198,61 +188,7 @@ def initialize end it 'returns default' do - expect(subject.body).to be nil - end - end - - describe '#file' do - before do - allow(subject).to receive(:warn) - end - - describe 'set' do - context 'as file path' do - let(:file_path) { '/some/file/path' } - - it 'emits a warning that this method is deprecated' do - expect(subject).to receive(:warn).with(/Use sendfile or stream/) - - subject.file file_path - end - - it 'forwards the call to sendfile' do - expect(subject).to receive(:sendfile).with(file_path) - - subject.file file_path - end - end - - context 'as object (backward compatibility)' do - let(:file_object) { double('StreamerObject', each: nil) } - - it 'emits a warning that this method is deprecated' do - expect(subject).to receive(:warn).with(/Use stream to use a Stream object/) - - subject.file file_object - end - - it 'forwards the call to stream' do - expect(subject).to receive(:stream).with(file_object) - - subject.file file_object - end - end - end - - describe 'get' do - it 'emits a warning that this method is deprecated' do - expect(subject).to receive(:warn).with(/Use sendfile or stream/) - - subject.file - end - - it 'fowards call to sendfile' do - expect(subject).to receive(:sendfile) - - subject.file - end + expect(subject.body).to be_nil end end @@ -267,39 +203,22 @@ def initialize end before do - subject.header 'Cache-Control', 'cache' - subject.header 'Content-Length', 123 + subject.header Rack::CACHE_CONTROL, 'cache' + subject.header Rack::CONTENT_LENGTH, 123 subject.header 'Transfer-Encoding', 'base64' - end - - it 'sends no deprecation warnings' do - expect(subject).to_not receive(:warn) - subject.sendfile file_path end it 'returns value wrapped in StreamResponse' do - subject.sendfile file_path - expect(subject.sendfile).to eq file_response end - it 'does not change the Cache-Control header' do - subject.sendfile file_path - - expect(subject.header['Cache-Control']).to eq 'cache' - end - - it 'does not change the Content-Length header' do - subject.sendfile file_path - - expect(subject.header['Content-Length']).to eq 123 - end - - it 'does not change the Transfer-Encoding header' do - subject.sendfile file_path - - expect(subject.header['Transfer-Encoding']).to eq 'base64' + it 'set the correct headers' do + expect(subject.header).to match( + Rack::CACHE_CONTROL => 'cache', + Rack::CONTENT_LENGTH => 123, + 'Transfer-Encoding' => 'base64' + ) end end @@ -313,7 +232,7 @@ def initialize end it 'returns default' do - expect(subject.sendfile).to be nil + expect(subject.sendfile).to be_nil end end @@ -328,45 +247,18 @@ def initialize end before do - subject.header 'Cache-Control', 'cache' - subject.header 'Content-Length', 123 + subject.header Rack::CACHE_CONTROL, 'cache' + subject.header Rack::CONTENT_LENGTH, 123 subject.header 'Transfer-Encoding', 'base64' - end - - it 'emits no deprecation warnings' do - expect(subject).to_not receive(:warn) - subject.stream file_path end it 'returns file body wrapped in StreamResponse' do - subject.stream file_path - expect(subject.stream).to eq file_response end - it 'sets Cache-Control header to no-cache' do - subject.stream file_path - - expect(subject.header['Cache-Control']).to eq 'no-cache' - end - - it 'does not change Cache-Control header' do - subject.stream - - expect(subject.header['Cache-Control']).to eq 'cache' - end - - it 'sets Content-Length header to nil' do - subject.stream file_path - - expect(subject.header['Content-Length']).to eq nil - end - - it 'sets Transfer-Encoding header to nil' do - subject.stream file_path - - expect(subject.header['Transfer-Encoding']).to eq nil + it 'sets only the cache-control header' do + expect(subject.header).to match(Rack::CACHE_CONTROL => 'no-cache') end end @@ -378,39 +270,18 @@ def initialize end before do - subject.header 'Cache-Control', 'cache' - subject.header 'Content-Length', 123 + subject.header Rack::CACHE_CONTROL, 'cache' + subject.header Rack::CONTENT_LENGTH, 123 subject.header 'Transfer-Encoding', 'base64' - end - - it 'emits no deprecation warnings' do - expect(subject).to_not receive(:warn) - subject.stream stream_object end it 'returns value wrapped in StreamResponse' do - subject.stream stream_object - expect(subject.stream).to eq stream_response end - it 'sets Cache-Control header to no-cache' do - subject.stream stream_object - - expect(subject.header['Cache-Control']).to eq 'no-cache' - end - - it 'sets Content-Length header to nil' do - subject.stream stream_object - - expect(subject.header['Content-Length']).to eq nil - end - - it 'sets Transfer-Encoding header to nil' do - subject.stream stream_object - - expect(subject.header['Transfer-Encoding']).to eq nil + it 'set only the cache-control header' do + expect(subject.header).to match(Rack::CACHE_CONTROL => 'no-cache') end end @@ -424,15 +295,15 @@ def initialize end it 'returns default' do - expect(subject.stream).to be nil - expect(subject.header['Cache-Control']).to eq nil + expect(subject.stream).to be_nil + expect(subject.header).to be_empty end end describe '#route' do before do - subject.env['grape.routing_args'] = {} - subject.env['grape.routing_args'][:route_info] = 'dummy' + subject.env[Grape::Env::GRAPE_ROUTING_ARGS] = {} + subject.env[Grape::Env::GRAPE_ROUTING_ARGS][:route_info] = 'dummy' end it 'returns route_info' do diff --git a/spec/grape/dsl/logger_spec.rb b/spec/grape/dsl/logger_spec.rb index 1992e3277..ae1bab567 100644 --- a/spec/grape/dsl/logger_spec.rb +++ b/spec/grape/dsl/logger_spec.rb @@ -1,28 +1,24 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Logger do + subject { Class.new(dummy_logger) } -module Grape - module DSL - module LoggerSpec - class Dummy - extend Grape::DSL::Logger - end + let(:dummy_logger) do + Class.new do + extend Grape::DSL::Logger end - describe Logger do - subject { Class.new(LoggerSpec::Dummy) } - let(:logger) { double(:logger) } + end + + let(:logger) { instance_double(Logger) } - describe '.logger' do - it 'sets a logger' do - subject.logger logger - expect(subject.logger).to eq logger - end + describe '.logger' do + it 'sets a logger' do + subject.logger logger + expect(subject.logger).to eq logger + end - it 'returns a logger' do - expect(subject.logger(logger)).to eq logger - end - end + it 'returns a logger' do + expect(subject.logger(logger)).to eq logger end end end diff --git a/spec/grape/dsl/middleware_spec.rb b/spec/grape/dsl/middleware_spec.rb index b116fb735..1b363697a 100644 --- a/spec/grape/dsl/middleware_spec.rb +++ b/spec/grape/dsl/middleware_spec.rb @@ -1,61 +1,56 @@ # frozen_string_literal: true -require 'spec_helper' - -module Grape - module DSL - module MiddlewareSpec - class Dummy - include Grape::DSL::Middleware - end +describe Grape::DSL::Middleware do + subject { dummy_class } + + let(:dummy_class) do + Class.new do + include Grape::DSL::Middleware end + end - describe Middleware do - subject { Class.new(MiddlewareSpec::Dummy) } - let(:proc) { ->() {} } - let(:foo_middleware) { Class.new } - let(:bar_middleware) { Class.new } + let(:proc) { -> {} } + let(:foo_middleware) { Class.new } + let(:bar_middleware) { Class.new } - describe '.use' do - it 'adds a middleware with the right operation' do - expect(subject).to receive(:namespace_stackable).with(:middleware, [:use, foo_middleware, :arg1, proc]) + describe '.use' do + it 'adds a middleware with the right operation' do + expect(subject).to receive(:namespace_stackable).with(:middleware, [:use, foo_middleware, :arg1, proc]) - subject.use foo_middleware, :arg1, &proc - end - end + subject.use foo_middleware, :arg1, &proc + end + end - describe '.insert' do - it 'adds a middleware with the right operation' do - expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert, 0, :arg1, proc]) + describe '.insert' do + it 'adds a middleware with the right operation' do + expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert, 0, :arg1, proc]) - subject.insert 0, :arg1, &proc - end - end + subject.insert 0, :arg1, &proc + end + end - describe '.insert_before' do - it 'adds a middleware with the right operation' do - expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert_before, foo_middleware, :arg1, proc]) + describe '.insert_before' do + it 'adds a middleware with the right operation' do + expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert_before, foo_middleware, :arg1, proc]) - subject.insert_before foo_middleware, :arg1, &proc - end - end + subject.insert_before foo_middleware, :arg1, &proc + end + end - describe '.insert_after' do - it 'adds a middleware with the right operation' do - expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert_after, foo_middleware, :arg1, proc]) + describe '.insert_after' do + it 'adds a middleware with the right operation' do + expect(subject).to receive(:namespace_stackable).with(:middleware, [:insert_after, foo_middleware, :arg1, proc]) - subject.insert_after foo_middleware, :arg1, &proc - end - end + subject.insert_after foo_middleware, :arg1, &proc + end + end - describe '.middleware' do - it 'returns the middleware stack' do - subject.use foo_middleware, :arg1, &proc - subject.insert_before bar_middleware, :arg1, :arg2 + describe '.middleware' do + it 'returns the middleware stack' do + subject.use foo_middleware, :arg1, &proc + subject.insert_before bar_middleware, :arg1, :arg2 - expect(subject.middleware).to eq [[:use, foo_middleware, :arg1, proc], [:insert_before, bar_middleware, :arg1, :arg2]] - end - end + expect(subject.middleware).to eq [[:use, foo_middleware, :arg1, proc], [:insert_before, bar_middleware, :arg1, :arg2]] end end end diff --git a/spec/grape/dsl/parameters_spec.rb b/spec/grape/dsl/parameters_spec.rb index bd60195e1..fbd6308ce 100644 --- a/spec/grape/dsl/parameters_spec.rb +++ b/spec/grape/dsl/parameters_spec.rb @@ -1,179 +1,285 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Parameters do + subject { dummy_class.new } -module Grape - module DSL - module ParametersSpec - class Dummy - include Grape::DSL::Parameters - attr_accessor :api, :element, :parent + let(:dummy_class) do + Class.new do + include Grape::DSL::Parameters + attr_accessor :api, :element, :parent - def validate_attributes(*args) - @validate_attributes = *args - end + def initialize + @validate_attributes = [] + end - def validate_attributes_reader - @validate_attributes - end + def validate_attributes(*args) + @validate_attributes.push(*args) + end - def push_declared_params(args, **_opts) - @push_declared_params = args - end + def validate_attributes_reader + @validate_attributes + end - def push_declared_params_reader - @push_declared_params - end + def push_declared_params(args, _opts) + @push_declared_params = args + end - def validates(*args) - @validates = *args - end + def push_declared_params_reader + @push_declared_params + end - def validates_reader - @validates - end + def validates(*args) + @validates = *args + end - def new_group_scope(args) - @group = args.clone.first - yield - end + def validates_reader + @validates + end - def extract_message_option(attrs) - return nil unless attrs.is_a?(Array) - opts = attrs.last.is_a?(Hash) ? attrs.pop : {} - opts.key?(:message) && !opts[:message].nil? ? opts.delete(:message) : nil - end + def new_scope(args, _, &block) + nested_scope = self.class.new + nested_scope.new_group_scope(args, &block) + nested_scope + end + + def new_group_scope(args) + prev_group = @group + @group = args.clone.first + yield + @group = prev_group + end + + def extract_message_option(attrs) + return nil unless attrs.is_a?(Array) + + opts = attrs.last.is_a?(Hash) ? attrs.pop : {} + opts.key?(:message) && !opts[:message].nil? ? opts.delete(:message) : nil end end + end - describe Parameters do - subject { ParametersSpec::Dummy.new } + describe '#use' do + before do + allow_message_expectations_on_nil + allow(subject.api).to receive(:namespace_stackable).with(:named_params) + end - describe '#use' do - before do - allow_message_expectations_on_nil - allow(subject.api).to receive(:namespace_stackable).with(:named_params) - end - let(:options) { { option: 'value' } } - let(:named_params) { { params_group: proc {} } } + let(:options) { { option: 'value' } } + let(:named_params) { { params_group: proc {} } } - it 'calls processes associated with named params' do - allow(subject.api).to receive(:namespace_stackable_with_hash).and_return(named_params) - expect(subject).to receive(:instance_exec).with(options).and_yield - subject.use :params_group, options - end + it 'calls processes associated with named params' do + allow(subject.api).to receive(:namespace_stackable_with_hash).and_return(named_params) + expect(subject).to receive(:instance_exec).with(options).and_yield + subject.use :params_group, options + end - it 'raises error when non-existent named param is called' do - allow(subject.api).to receive(:namespace_stackable_with_hash).and_return({}) - expect { subject.use :params_group }.to raise_error('Params :params_group not found!') - end - end + it 'raises error when non-existent named param is called' do + allow(subject.api).to receive(:namespace_stackable_with_hash).and_return({}) + expect { subject.use :params_group }.to raise_error('Params :params_group not found!') + end + end - describe '#use_scope' do - it 'is alias to #use' do - expect(subject.method(:use_scope)).to eq subject.method(:use) - end - end + describe '#use_scope' do + it 'is alias to #use' do + expect(subject.method(:use_scope)).to eq subject.method(:use) + end + end - describe '#includes' do - it 'is alias to #use' do - expect(subject.method(:includes)).to eq subject.method(:use) - end - end + describe '#includes' do + it 'is alias to #use' do + expect(subject.method(:includes)).to eq subject.method(:use) + end + end - describe '#requires' do - it 'adds a required parameter' do - subject.requires :id, type: Integer, desc: 'Identity.' + describe '#requires' do + it 'adds a required parameter' do + subject.requires :id, type: Integer, desc: 'Identity.' - expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.', presence: { value: true, message: nil } }]) - expect(subject.push_declared_params_reader).to eq([:id]) - end - end + expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.', presence: { value: true, message: nil } }]) + expect(subject.push_declared_params_reader).to eq([:id]) + end + end - describe '#optional' do - it 'adds an optional parameter' do - subject.optional :id, type: Integer, desc: 'Identity.' + describe '#optional' do + it 'adds an optional parameter' do + subject.optional :id, type: Integer, desc: 'Identity.' - expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.' }]) - expect(subject.push_declared_params_reader).to eq([:id]) - end - end + expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.' }]) + expect(subject.push_declared_params_reader).to eq([:id]) + end + end - describe '#with' do - it 'creates a scope with group attributes' do - subject.with(type: Integer) { subject.optional :id, desc: 'Identity.' } + describe '#with' do + it 'creates a scope with group attributes' do + subject.with(type: Integer) { subject.optional :id, desc: 'Identity.' } - expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.' }]) - expect(subject.push_declared_params_reader).to eq([:id]) - end + expect(subject.validate_attributes_reader).to eq([[:id], { type: Integer, desc: 'Identity.' }]) + expect(subject.push_declared_params_reader).to eq([:id]) + end + + it 'merges the group attributes' do + subject.with(documentation: { in: 'body' }) { subject.optional :vault, documentation: { default: 33 } } + + expect(subject.validate_attributes_reader).to eq([[:vault], { documentation: { in: 'body', default: 33 } }]) + expect(subject.push_declared_params_reader).to eq([:vault]) + end + + it 'overrides the group attribute when values not mergable' do + subject.with(type: Integer, documentation: { in: 'body', default: 33 }) do + subject.optional :vault + subject.optional :allowed_vaults, type: [Integer], documentation: { default: [31, 32, 33], is_array: true } end - describe '#mutually_exclusive' do - it 'adds an mutally exclusive parameter validation' do - subject.mutually_exclusive :media, :audio + expect(subject.validate_attributes_reader).to eq( + [ + [:vault], { type: Integer, documentation: { in: 'body', default: 33 } }, + [:allowed_vaults], { type: [Integer], documentation: { in: 'body', default: [31, 32, 33], is_array: true } } + ] + ) + end - expect(subject.validates_reader).to eq([%i[media audio], { mutual_exclusion: { value: true, message: nil } }]) - end + it 'allows a primitive type attribite to overwrite a complex type group attribute' do + subject.with(documentation: { x: { nullable: true } }) do + subject.optional :vault, type: Integer, documentation: { x: nil } end - describe '#exactly_one_of' do - it 'adds an exactly of one parameter validation' do - subject.exactly_one_of :media, :audio + expect(subject.validate_attributes_reader).to eq( + [ + [:vault], { type: Integer, documentation: { x: nil } } + ] + ) + end - expect(subject.validates_reader).to eq([%i[media audio], { exactly_one_of: { value: true, message: nil } }]) - end + it 'does not nest primitives inside existing complex types erroneously' do + subject.with(type: Hash, documentation: { default: { vault: '33' } }) do + subject.optional :info + subject.optional :role, type: String, documentation: { default: 'resident' } end - describe '#at_least_one_of' do - it 'adds an at least one of parameter validation' do - subject.at_least_one_of :media, :audio + expect(subject.validate_attributes_reader).to eq( + [ + [:info], { type: Hash, documentation: { default: { vault: '33' } } }, + [:role], { type: String, documentation: { default: 'resident' } } + ] + ) + end - expect(subject.validates_reader).to eq([%i[media audio], { at_least_one_of: { value: true, message: nil } }]) - end + it 'merges deeply nested attributes' do + subject.with(documentation: { details: { in: 'body', hidden: false } }) do + subject.optional :vault, documentation: { details: { desc: 'The vault number' } } end - describe '#all_or_none_of' do - it 'adds an all or none of parameter validation' do - subject.all_or_none_of :media, :audio + expect(subject.validate_attributes_reader).to eq( + [ + [:vault], { documentation: { details: { in: 'body', hidden: false, desc: 'The vault number' } } } + ] + ) + end - expect(subject.validates_reader).to eq([%i[media audio], { all_or_none_of: { value: true, message: nil } }]) + it "supports nested 'with' calls" do + subject.with(type: Integer, documentation: { in: 'body' }) do + subject.optional :pipboy_id + subject.with(documentation: { default: 33 }) do + subject.optional :vault + subject.with(type: String) do + subject.with(documentation: { default: 'resident' }) do + subject.optional :role + end + end + subject.optional :age, documentation: { default: 42 } end end - describe '#group' do - it 'is alias to #requires' do - expect(subject.method(:group)).to eq subject.method(:requires) + expect(subject.validate_attributes_reader).to eq( + [ + [:pipboy_id], { type: Integer, documentation: { in: 'body' } }, + [:vault], { type: Integer, documentation: { in: 'body', default: 33 } }, + [:role], { type: String, documentation: { in: 'body', default: 'resident' } }, + [:age], { type: Integer, documentation: { in: 'body', default: 42 } } + ] + ) + end + + it "supports Hash parameter inside the 'with' calls" do + subject.with(documentation: { in: 'body' }) do + subject.optional :info, type: Hash, documentation: { x: { nullable: true }, desc: 'The info' } do + subject.optional :vault, type: Integer, documentation: { default: 33, desc: 'The vault number' } end end - describe '#params' do - it 'inherits params from parent' do - parent_params = { foo: 'bar' } - subject.parent = Object.new - allow(subject.parent).to receive(:params).and_return(parent_params) - expect(subject.params({})).to eq parent_params - end + expect(subject.validate_attributes_reader).to eq( + [ + [:info], { type: Hash, documentation: { in: 'body', desc: 'The info', x: { nullable: true } } }, + [:vault], { type: Integer, documentation: { in: 'body', default: 33, desc: 'The vault number' } } + ] + ) + end + end - describe 'when params argument is an array of hashes' do - it 'returns values of each hash for @element key' do - subject.element = :foo - expect(subject.params([{ foo: 'bar' }, { foo: 'baz' }])).to eq(%w[bar baz]) - end - end + describe '#mutually_exclusive' do + it 'adds an mutally exclusive parameter validation' do + subject.mutually_exclusive :media, :audio - describe 'when params argument is a hash' do - it 'returns value for @element key' do - subject.element = :foo - expect(subject.params(foo: 'bar')).to eq('bar') - end - end + expect(subject.validates_reader).to eq([%i[media audio], { mutual_exclusion: { value: true, message: nil } }]) + end + end - describe 'when params argument is not a array or a hash' do - it 'returns empty hash' do - subject.element = Object.new - expect(subject.params(Object.new)).to eq({}) - end - end + describe '#exactly_one_of' do + it 'adds an exactly of one parameter validation' do + subject.exactly_one_of :media, :audio + + expect(subject.validates_reader).to eq([%i[media audio], { exactly_one_of: { value: true, message: nil } }]) + end + end + + describe '#at_least_one_of' do + it 'adds an at least one of parameter validation' do + subject.at_least_one_of :media, :audio + + expect(subject.validates_reader).to eq([%i[media audio], { at_least_one_of: { value: true, message: nil } }]) + end + end + + describe '#all_or_none_of' do + it 'adds an all or none of parameter validation' do + subject.all_or_none_of :media, :audio + + expect(subject.validates_reader).to eq([%i[media audio], { all_or_none_of: { value: true, message: nil } }]) + end + end + + describe '#group' do + it 'is alias to #requires' do + expect(subject.method(:group)).to eq subject.method(:requires) + end + end + + describe '#params' do + it 'inherits params from parent' do + parent_params = { foo: 'bar' } + subject.parent = Object.new + allow(subject.parent).to receive_messages(params: parent_params, params_meeting_dependency: nil) + expect(subject.params({})).to eq parent_params + end + + describe 'when params argument is an array of hashes' do + it 'returns values of each hash for @element key' do + subject.element = :foo + expect(subject.params([{ foo: 'bar' }, { foo: 'baz' }])).to eq(%w[bar baz]) + end + end + + describe 'when params argument is a hash' do + it 'returns value for @element key' do + subject.element = :foo + expect(subject.params(foo: 'bar')).to eq('bar') + end + end + + describe 'when params argument is not a array or a hash' do + it 'returns empty hash' do + subject.element = Object.new + expect(subject.params(Object.new)).to eq({}) end end end diff --git a/spec/grape/dsl/request_response_spec.rb b/spec/grape/dsl/request_response_spec.rb index 00d9e3a6a..24bedc65b 100644 --- a/spec/grape/dsl/request_response_spec.rb +++ b/spec/grape/dsl/request_response_spec.rb @@ -1,207 +1,221 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::RequestResponse do + subject { dummy_class } -module Grape - module DSL - module RequestResponseSpec - class Dummy - include Grape::DSL::RequestResponse + let(:dummy_class) do + Class.new do + include Grape::DSL::RequestResponse - def self.set(key, value) - settings[key.to_sym] = value - end + def self.set(key, value) + settings[key.to_sym] = value + end - def self.imbue(key, value) - settings.imbue(key, value) - end + def self.imbue(key, value) + settings.imbue(key, value) end end + end - describe RequestResponse do - subject { Class.new(RequestResponseSpec::Dummy) } - let(:c_type) { 'application/json' } - let(:format) { 'txt' } + let(:c_type) { 'application/json' } + let(:format) { 'txt' } - describe '.default_format' do - it 'sets the default format' do - expect(subject).to receive(:namespace_inheritable).with(:default_format, :format) - subject.default_format :format - end + describe '.default_format' do + it 'sets the default format' do + expect(subject).to receive(:namespace_inheritable).with(:default_format, :format) + subject.default_format :format + end - it 'returns the format without paramter' do - subject.default_format :format + it 'returns the format without paramter' do + subject.default_format :format - expect(subject.default_format).to eq :format - end - end + expect(subject.default_format).to eq :format + end + end - describe '.format' do - it 'sets a new format' do - expect(subject).to receive(:namespace_inheritable).with(:format, format.to_sym) - expect(subject).to receive(:namespace_inheritable).with(:default_error_formatter, Grape::ErrorFormatter::Txt) + describe '.format' do + it 'sets a new format' do + expect(subject).to receive(:namespace_inheritable).with(:format, format.to_sym) + expect(subject).to receive(:namespace_inheritable).with(:default_error_formatter, Grape::ErrorFormatter::Txt) - subject.format format - end - end + subject.format format + end + end - describe '.formatter' do - it 'sets the formatter for a content type' do - expect(subject).to receive(:namespace_stackable).with(:formatters, c_type.to_sym => :formatter) - subject.formatter c_type, :formatter - end - end + describe '.formatter' do + it 'sets the formatter for a content type' do + expect(subject).to receive(:namespace_stackable).with(:formatters, c_type.to_sym => :formatter) + subject.formatter c_type, :formatter + end + end - describe '.parser' do - it 'sets a parser for a content type' do - expect(subject).to receive(:namespace_stackable).with(:parsers, c_type.to_sym => :parser) - subject.parser c_type, :parser - end - end + describe '.parser' do + it 'sets a parser for a content type' do + expect(subject).to receive(:namespace_stackable).with(:parsers, c_type.to_sym => :parser) + subject.parser c_type, :parser + end + end - describe '.default_error_formatter' do - it 'sets a new error formatter' do - expect(subject).to receive(:namespace_inheritable).with(:default_error_formatter, Grape::ErrorFormatter::Json) - subject.default_error_formatter :json - end - end + describe '.default_error_formatter' do + it 'sets a new error formatter' do + expect(subject).to receive(:namespace_inheritable).with(:default_error_formatter, Grape::ErrorFormatter::Json) + subject.default_error_formatter :json + end + end - describe '.error_formatter' do - it 'sets a error_formatter' do - format = 'txt' - expect(subject).to receive(:namespace_stackable).with(:error_formatters, format.to_sym => :error_formatter) - subject.error_formatter format, :error_formatter - end + describe '.error_formatter' do + it 'sets a error_formatter' do + format = 'txt' + expect(subject).to receive(:namespace_stackable).with(:error_formatters, format.to_sym => :error_formatter) + subject.error_formatter format, :error_formatter + end - it 'understands syntactic sugar' do - expect(subject).to receive(:namespace_stackable).with(:error_formatters, format.to_sym => :error_formatter) - subject.error_formatter format, with: :error_formatter - end - end + it 'understands syntactic sugar' do + expect(subject).to receive(:namespace_stackable).with(:error_formatters, format.to_sym => :error_formatter) + subject.error_formatter format, with: :error_formatter + end + end - describe '.content_type' do - it 'sets a content type for a format' do - expect(subject).to receive(:namespace_stackable).with(:content_types, format.to_sym => c_type) - subject.content_type format, c_type - end - end + describe '.content_type' do + it 'sets a content type for a format' do + expect(subject).to receive(:namespace_stackable).with(:content_types, format.to_sym => c_type) + subject.content_type format, c_type + end + end - describe '.content_types' do - it 'returns all content types' do - expect(subject.content_types).to eq(xml: 'application/xml', - serializable_hash: 'application/json', - json: 'application/json', - txt: 'text/plain', - binary: 'application/octet-stream') - end - end + describe '.content_types' do + it 'returns all content types' do + expect(subject.content_types).to eq(xml: 'application/xml', + serializable_hash: 'application/json', + json: 'application/json', + txt: 'text/plain', + binary: 'application/octet-stream') + end + end + + describe '.default_error_status' do + it 'sets a default error status' do + expect(subject).to receive(:namespace_inheritable).with(:default_error_status, 500) + subject.default_error_status 500 + end + end - describe '.default_error_status' do - it 'sets a default error status' do - expect(subject).to receive(:namespace_inheritable).with(:default_error_status, 500) - subject.default_error_status 500 - end + describe '.rescue_from' do + describe ':all' do + it 'sets rescue all to true' do + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, nil) + subject.rescue_from :all end - describe '.rescue_from' do - describe ':all' do - it 'sets rescue all to true' do - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, nil) - subject.rescue_from :all - end + it 'sets given proc as rescue handler' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, rescue_handler_proc) + subject.rescue_from :all, rescue_handler_proc + end - it 'sets given proc as rescue handler' do - rescue_handler_proc = proc {} - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, rescue_handler_proc) - subject.rescue_from :all, rescue_handler_proc - end + it 'sets given block as rescue handler' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, rescue_handler_proc) + subject.rescue_from :all, &rescue_handler_proc + end - it 'sets given block as rescue handler' do - rescue_handler_proc = proc {} - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, rescue_handler_proc) - subject.rescue_from :all, &rescue_handler_proc - end + it 'sets a rescue handler declared through :with option' do + with_block = -> { 'hello' } + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, an_instance_of(Proc)) + subject.rescue_from :all, with: with_block + end - it 'sets a rescue handler declared through :with option' do - with_block = -> { 'hello' } - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:all_rescue_handler, an_instance_of(Proc)) - subject.rescue_from :all, with: with_block - end + it 'abort if :with option value is not Symbol, String or Proc' do + expect { subject.rescue_from :all, with: 1234 }.to raise_error(ArgumentError, "with: #{integer_class_name}, expected Symbol, String or Proc") + end - it 'abort if :with option value is not Symbol, String or Proc' do - expect { subject.rescue_from :all, with: 1234 }.to raise_error(ArgumentError, "with: #{integer_class_name}, expected Symbol, String or Proc") + it 'abort if both :with option and block are passed' do + expect do + subject.rescue_from :all, with: -> { 'hello' } do + error!('bye') end + end.to raise_error(ArgumentError, 'both :with option and block cannot be passed') + end + end - it 'abort if both :with option and block are passed' do - expect do - subject.rescue_from :all, with: -> { 'hello' } do - error!('bye') - end - end.to raise_error(ArgumentError, 'both :with option and block cannot be passed') - end - end + describe ':grape_exceptions' do + it 'sets rescue all to true' do + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) + expect(subject).to receive(:namespace_inheritable).with(:grape_exceptions_rescue_handler, nil) + subject.rescue_from :grape_exceptions + end - describe ':grape_exceptions' do - it 'sets rescue all to true' do - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) - subject.rescue_from :grape_exceptions - end + it 'sets given proc as rescue handler' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) + expect(subject).to receive(:namespace_inheritable).with(:grape_exceptions_rescue_handler, rescue_handler_proc) + subject.rescue_from :grape_exceptions, rescue_handler_proc + end - it 'sets rescue_grape_exceptions to true' do - expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) - expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) - subject.rescue_from :grape_exceptions - end - end + it 'sets given block as rescue handler' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) + expect(subject).to receive(:namespace_inheritable).with(:grape_exceptions_rescue_handler, rescue_handler_proc) + subject.rescue_from :grape_exceptions, &rescue_handler_proc + end - describe 'list of exceptions is passed' do - it 'sets hash of exceptions as rescue handlers' do - expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, StandardError => nil) - expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) - subject.rescue_from StandardError - end + it 'sets a rescue handler declared through :with option' do + with_block = -> { 'hello' } + expect(subject).to receive(:namespace_inheritable).with(:rescue_all, true) + expect(subject).to receive(:namespace_inheritable).with(:rescue_grape_exceptions, true) + expect(subject).to receive(:namespace_inheritable).with(:grape_exceptions_rescue_handler, an_instance_of(Proc)) + subject.rescue_from :grape_exceptions, with: with_block + end + end - it 'rescues only base handlers if rescue_subclasses: false option is passed' do - expect(subject).to receive(:namespace_reverse_stackable).with(:base_only_rescue_handlers, StandardError => nil) - expect(subject).to receive(:namespace_stackable).with(:rescue_options, rescue_subclasses: false) - subject.rescue_from StandardError, rescue_subclasses: false - end + describe 'list of exceptions is passed' do + it 'sets hash of exceptions as rescue handlers' do + expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, { StandardError => nil }) + expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) + subject.rescue_from StandardError + end - it 'sets given proc as rescue handler for each key in hash' do - rescue_handler_proc = proc {} - expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, StandardError => rescue_handler_proc) - expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) - subject.rescue_from StandardError, rescue_handler_proc - end + it 'rescues only base handlers if rescue_subclasses: false option is passed' do + expect(subject).to receive(:namespace_reverse_stackable).with(:base_only_rescue_handlers, { StandardError => nil }) + expect(subject).to receive(:namespace_stackable).with(:rescue_options, { rescue_subclasses: false }) + subject.rescue_from StandardError, rescue_subclasses: false + end - it 'sets given block as rescue handler for each key in hash' do - rescue_handler_proc = proc {} - expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, StandardError => rescue_handler_proc) - expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) - subject.rescue_from StandardError, &rescue_handler_proc - end + it 'sets given proc as rescue handler for each key in hash' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, { StandardError => rescue_handler_proc }) + expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) + subject.rescue_from StandardError, rescue_handler_proc + end - it 'sets a rescue handler declared through :with option for each key in hash' do - with_block = -> { 'hello' } - expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, StandardError => an_instance_of(Proc)) - expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) - subject.rescue_from StandardError, with: with_block - end - end + it 'sets given block as rescue handler for each key in hash' do + rescue_handler_proc = proc {} + expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, { StandardError => rescue_handler_proc }) + expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) + subject.rescue_from StandardError, &rescue_handler_proc end - describe '.represent' do - it 'sets a presenter for a class' do - presenter = Class.new - expect(subject).to receive(:namespace_stackable).with(:representations, ThisClass: presenter) - subject.represent :ThisClass, with: presenter - end + it 'sets a rescue handler declared through :with option for each key in hash' do + with_block = -> { 'hello' } + expect(subject).to receive(:namespace_reverse_stackable).with(:rescue_handlers, { StandardError => an_instance_of(Proc) }) + expect(subject).to receive(:namespace_stackable).with(:rescue_options, {}) + subject.rescue_from StandardError, with: with_block end end end + + describe '.represent' do + it 'sets a presenter for a class' do + presenter = Class.new + expect(subject).to receive(:namespace_stackable).with(:representations, ThisClass: presenter) + subject.represent :ThisClass, with: presenter + end + end end diff --git a/spec/grape/dsl/routing_spec.rb b/spec/grape/dsl/routing_spec.rb index e26d039d3..1b54ba913 100644 --- a/spec/grape/dsl/routing_spec.rb +++ b/spec/grape/dsl/routing_spec.rb @@ -1,274 +1,271 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Routing do + subject { dummy_class } -module Grape - module DSL - module RoutingSpec - class Dummy - include Grape::DSL::Routing - end + let(:dummy_class) do + Class.new do + include Grape::DSL::Routing end + end - describe Routing do - subject { Class.new(RoutingSpec::Dummy) } - let(:proc) { ->() {} } - let(:options) { { a: :b } } - let(:path) { '/dummy' } + let(:proc) { -> {} } + let(:options) { { a: :b } } + let(:path) { '/dummy' } - describe '.version' do - it 'sets a version for route' do - version = 'v1' - expect(subject).to receive(:namespace_inheritable).with(:version, [version]) - expect(subject).to receive(:namespace_inheritable).with(:version_options, using: :path) - expect(subject.version(version)).to eq(version) - end - end + describe '.version' do + it 'sets a version for route' do + version = 'v1' + expect(subject).to receive(:namespace_inheritable).with(:version, [version]) + expect(subject).to receive(:namespace_inheritable).with(:version_options, { using: :path }) + expect(subject.version(version)).to eq(version) + end + end - describe '.prefix' do - it 'sets a prefix for route' do - prefix = '/api' - expect(subject).to receive(:namespace_inheritable).with(:root_prefix, prefix) - subject.prefix prefix - end - end + describe '.prefix' do + it 'sets a prefix for route' do + prefix = '/api' + expect(subject).to receive(:namespace_inheritable).with(:root_prefix, prefix) + subject.prefix prefix + end + end - describe '.scope' do - it 'create a scope without affecting the URL' do - expect(subject).to receive(:within_namespace) - subject.scope {} - end - end + describe '.scope' do + it 'create a scope without affecting the URL' do + expect(subject).to receive(:within_namespace) + subject.scope {} + end + end - describe '.do_not_route_head!' do - it 'sets do not route head option' do - expect(subject).to receive(:namespace_inheritable).with(:do_not_route_head, true) - subject.do_not_route_head! - end - end + describe '.do_not_route_head!' do + it 'sets do not route head option' do + expect(subject).to receive(:namespace_inheritable).with(:do_not_route_head, true) + subject.do_not_route_head! + end + end - describe '.do_not_route_options!' do - it 'sets do not route options option' do - expect(subject).to receive(:namespace_inheritable).with(:do_not_route_options, true) - subject.do_not_route_options! - end - end + describe '.do_not_route_options!' do + it 'sets do not route options option' do + expect(subject).to receive(:namespace_inheritable).with(:do_not_route_options, true) + subject.do_not_route_options! + end + end - describe '.mount' do - it 'mounts on a nested path' do - subject = Class.new(Grape::API) - app1 = Class.new(Grape::API) - app2 = Class.new(Grape::API) - app2.get '/nice' do - 'play' - end - - subject.mount app1 => '/app1' - app1.mount app2 => '/app2' - - expect(subject.inheritable_setting.to_hash[:namespace]).to eq({}) - expect(subject.inheritable_setting.to_hash[:namespace_inheritable]).to eq({}) - expect(app1.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1']) - - expect(app2.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1', '/app2']) - end - - it 'mounts multiple routes at once' do - base_app = Class.new(Grape::API) - app1 = Class.new(Grape::API) - app2 = Class.new(Grape::API) - base_app.mount(app1 => '/app1', app2 => '/app2') - - expect(app1.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1']) - expect(app2.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app2']) - end + describe '.mount' do + it 'mounts on a nested path' do + subject = Class.new(Grape::API) + app1 = Class.new(Grape::API) + app2 = Class.new(Grape::API) + app2.get '/nice' do + 'play' end - describe '.route' do - before do - allow(subject).to receive(:endpoints).and_return([]) - allow(subject).to receive(:route_end) - allow(subject).to receive(:reset_validations!) - end - - it 'marks end of the route' do - expect(subject).to receive(:route_end) - subject.route(:any) - end - - it 'resets validations' do - expect(subject).to receive(:reset_validations!) - subject.route(:any) - end - - it 'defines a new endpoint' do - expect { subject.route(:any) } - .to change { subject.endpoints.count }.from(0).to(1) - end - - it 'does not duplicate identical endpoints' do - subject.route(:any) - expect { subject.route(:any) } - .to_not change(subject.endpoints, :count) - end - - it 'generates correct endpoint options' do - allow(subject).to receive(:route_setting).with(:description).and_return(fiz: 'baz') - allow(subject).to receive(:namespace_stackable_with_hash).and_return(nuz: 'naz') - - expect(Grape::Endpoint).to receive(:new) do |_inheritable_setting, endpoint_options| - expect(endpoint_options[:method]).to eq :get - expect(endpoint_options[:path]).to eq '/foo' - expect(endpoint_options[:for]).to eq subject - expect(endpoint_options[:route_options]).to eq(foo: 'bar', fiz: 'baz', params: { nuz: 'naz' }) - end.and_yield - - subject.route(:get, '/foo', { foo: 'bar' }, &proc {}) - end - end + subject.mount app1 => '/app1' + app1.mount app2 => '/app2' - describe '.get' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('GET', path, options) - subject.get path, options, &proc - end - end + expect(subject.inheritable_setting.to_hash[:namespace]).to eq({}) + expect(subject.inheritable_setting.to_hash[:namespace_inheritable]).to eq({}) + expect(app1.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1']) - describe '.post' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('POST', path, options) - subject.post path, options, &proc - end - end + expect(app2.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1', '/app2']) + end - describe '.put' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('PUT', path, options) - subject.put path, options, &proc - end - end + it 'mounts multiple routes at once' do + base_app = Class.new(Grape::API) + app1 = Class.new(Grape::API) + app2 = Class.new(Grape::API) + base_app.mount(app1 => '/app1', app2 => '/app2') - describe '.head' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('HEAD', path, options) - subject.head path, options, &proc - end - end + expect(app1.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app1']) + expect(app2.inheritable_setting.to_hash[:namespace_stackable]).to eq(mount_path: ['/app2']) + end + end - describe '.delete' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('DELETE', path, options) - subject.delete path, options, &proc - end - end + describe '.route' do + before do + allow(subject).to receive(:endpoints).and_return([]) + allow(subject).to receive(:route_end) + allow(subject).to receive(:reset_validations!) + end - describe '.options' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('OPTIONS', path, options) - subject.options path, options, &proc - end - end + it 'marks end of the route' do + expect(subject).to receive(:route_end) + subject.route(:any) + end - describe '.patch' do - it 'delegates to .route' do - expect(subject).to receive(:route).with('PATCH', path, options) - subject.patch path, options, &proc - end - end + it 'resets validations' do + expect(subject).to receive(:reset_validations!) + subject.route(:any) + end - describe '.namespace' do - let(:new_namespace) { Object.new } + it 'defines a new endpoint' do + expect { subject.route(:any) } + .to change { subject.endpoints.count }.from(0).to(1) + end - it 'creates a new namespace with given name and options' do - expect(subject).to receive(:within_namespace).and_yield - expect(subject).to receive(:nest).and_yield - expect(Namespace).to receive(:new).with(:foo, foo: 'bar').and_return(new_namespace) - expect(subject).to receive(:namespace_stackable).with(:namespace, new_namespace) + it 'does not duplicate identical endpoints' do + subject.route(:any) + expect { subject.route(:any) } + .not_to change(subject.endpoints, :count) + end - subject.namespace :foo, foo: 'bar', &proc {} - end + it 'generates correct endpoint options' do + allow(subject).to receive(:route_setting).with(:description).and_return(fiz: 'baz') + allow(subject).to receive(:namespace_stackable_with_hash).and_return(nuz: 'naz') - it 'calls #joined_space_path on Namespace' do - result_of_namspace_stackable = Object.new - allow(subject).to receive(:namespace_stackable).and_return(result_of_namspace_stackable) - expect(Namespace).to receive(:joined_space_path).with(result_of_namspace_stackable) - subject.namespace - end - end + expect(Grape::Endpoint).to receive(:new) do |_inheritable_setting, endpoint_options| + expect(endpoint_options[:method]).to eq :get + expect(endpoint_options[:path]).to eq '/foo' + expect(endpoint_options[:for]).to eq subject + expect(endpoint_options[:route_options]).to eq(foo: 'bar', fiz: 'baz', params: { nuz: 'naz' }) + end.and_yield - describe '.group' do - it 'is alias to #namespace' do - expect(subject.method(:group)).to eq subject.method(:namespace) - end - end + subject.route(:get, '/foo', { foo: 'bar' }, &proc {}) + end + end - describe '.resource' do - it 'is alias to #namespace' do - expect(subject.method(:resource)).to eq subject.method(:namespace) - end - end + describe '.get' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::GET, path, options) + subject.get path, options, &proc + end + end - describe '.resources' do - it 'is alias to #namespace' do - expect(subject.method(:resources)).to eq subject.method(:namespace) - end - end + describe '.post' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::POST, path, options) + subject.post path, options, &proc + end + end - describe '.segment' do - it 'is alias to #namespace' do - expect(subject.method(:segment)).to eq subject.method(:namespace) - end - end + describe '.put' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::PUT, path, options) + subject.put path, options, &proc + end + end + + describe '.head' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::HEAD, path, options) + subject.head path, options, &proc + end + end + + describe '.delete' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::DELETE, path, options) + subject.delete path, options, &proc + end + end + + describe '.options' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::OPTIONS, path, options) + subject.options path, options, &proc + end + end + + describe '.patch' do + it 'delegates to .route' do + expect(subject).to receive(:route).with(Rack::PATCH, path, options) + subject.patch path, options, &proc + end + end + + describe '.namespace' do + let(:new_namespace) { Object.new } + + it 'creates a new namespace with given name and options' do + expect(subject).to receive(:within_namespace).and_yield + expect(subject).to receive(:nest).and_yield + expect(Grape::Namespace).to receive(:new).with(:foo, { foo: 'bar' }).and_return(new_namespace) + expect(subject).to receive(:namespace_stackable).with(:namespace, new_namespace) + + subject.namespace :foo, foo: 'bar', &proc {} + end + + it 'calls #joined_space_path on Namespace' do + result_of_namspace_stackable = Object.new + allow(subject).to receive(:namespace_stackable).and_return(result_of_namspace_stackable) + expect(Grape::Namespace).to receive(:joined_space_path).with(result_of_namspace_stackable) + subject.namespace + end + end + + describe '.group' do + it 'is alias to #namespace' do + expect(subject.method(:group)).to eq subject.method(:namespace) + end + end - describe '.routes' do - let(:routes) { Object.new } - - it 'returns value received from #prepare_routes' do - expect(subject).to receive(:prepare_routes).and_return(routes) - expect(subject.routes).to eq routes - end - - context 'when #routes was already called once' do - before do - allow(subject).to receive(:prepare_routes).and_return(routes) - subject.routes - end - it 'it does not call prepare_routes again' do - expect(subject).to_not receive(:prepare_routes) - expect(subject.routes).to eq routes - end - end + describe '.resource' do + it 'is alias to #namespace' do + expect(subject.method(:resource)).to eq subject.method(:namespace) + end + end + + describe '.resources' do + it 'is alias to #namespace' do + expect(subject.method(:resources)).to eq subject.method(:namespace) + end + end + + describe '.segment' do + it 'is alias to #namespace' do + expect(subject.method(:segment)).to eq subject.method(:namespace) + end + end + + describe '.routes' do + let(:routes) { Object.new } + + it 'returns value received from #prepare_routes' do + expect(subject).to receive(:prepare_routes).and_return(routes) + expect(subject.routes).to eq routes + end + + context 'when #routes was already called once' do + before do + allow(subject).to receive(:prepare_routes).and_return(routes) + subject.routes end - describe '.route_param' do - it 'calls #namespace with given params' do - expect(subject).to receive(:namespace).with(':foo', {}).and_yield - subject.route_param('foo', {}, &proc {}) - end - - let(:regex) { /(.*)/ } - let!(:options) { { requirements: regex } } - it 'nests requirements option under param name' do - expect(subject).to receive(:namespace) do |_param, options| - expect(options[:requirements][:foo]).to eq regex - end - subject.route_param('foo', options, &proc {}) - end - - it 'does not modify options parameter' do - allow(subject).to receive(:namespace) - expect { subject.route_param('foo', options, &proc {}) } - .to_not change { options } - end + it 'does not call prepare_routes again' do + expect(subject).not_to receive(:prepare_routes) + expect(subject.routes).to eq routes end + end + end + + describe '.route_param' do + let!(:options) { { requirements: regex } } + let(:regex) { /(.*)/ } + + it 'calls #namespace with given params' do + expect(subject).to receive(:namespace).with(':foo', {}).and_yield + subject.route_param('foo', {}, &proc {}) + end - describe '.versions' do - it 'returns last defined version' do - subject.version 'v1' - subject.version 'v2' - expect(subject.version).to eq('v2') - end + it 'nests requirements option under param name' do + expect(subject).to receive(:namespace) do |_param, options| + expect(options[:requirements][:foo]).to eq regex end + subject.route_param('foo', options, &proc {}) + end + + it 'does not modify options parameter' do + allow(subject).to receive(:namespace) + expect { subject.route_param('foo', options, &proc {}) } + .not_to(change { options }) + end + end + + describe '.versions' do + it 'returns last defined version' do + subject.version 'v1' + subject.version 'v2' + expect(subject.version).to eq('v2') end end end diff --git a/spec/grape/dsl/settings_spec.rb b/spec/grape/dsl/settings_spec.rb index b2520d36f..e4b20fa36 100644 --- a/spec/grape/dsl/settings_spec.rb +++ b/spec/grape/dsl/settings_spec.rb @@ -1,263 +1,257 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::DSL::Settings do + subject { dummy_class.new } -module Grape - module DSL - module SettingsSpec - class Dummy - include Grape::DSL::Settings + let(:dummy_class) do + Class.new do + include Grape::DSL::Settings - def reset_validations!; end - end + def reset_validations!; end end + end - describe Settings do - subject { SettingsSpec::Dummy.new } + describe '#unset' do + it 'deletes a key from settings' do + subject.namespace_setting :dummy, 1 + expect(subject.inheritable_setting.namespace.keys).to include(:dummy) - describe '#unset' do - it 'deletes a key from settings' do - subject.namespace_setting :dummy, 1 - expect(subject.inheritable_setting.namespace.keys).to include(:dummy) + subject.unset :namespace, :dummy + expect(subject.inheritable_setting.namespace.keys).not_to include(:dummy) + end + end - subject.unset :namespace, :dummy - expect(subject.inheritable_setting.namespace.keys).not_to include(:dummy) - end - end + describe '#get_or_set' do + it 'sets a values' do + subject.get_or_set :namespace, :dummy, 1 + expect(subject.namespace_setting(:dummy)).to eq 1 + end - describe '#get_or_set' do - it 'sets a values' do - subject.get_or_set :namespace, :dummy, 1 - expect(subject.namespace_setting(:dummy)).to eq 1 - end + it 'returns a value when nil is new value is provided' do + subject.get_or_set :namespace, :dummy, 1 + expect(subject.get_or_set(:namespace, :dummy, nil)).to eq 1 + end + end - it 'returns a value when nil is new value is provided' do - subject.get_or_set :namespace, :dummy, 1 - expect(subject.get_or_set(:namespace, :dummy, nil)).to eq 1 - end - end + describe '#global_setting' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:global, :dummy, 1) + subject.global_setting(:dummy, 1) + end + end - describe '#global_setting' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:global, :dummy, 1) - subject.global_setting(:dummy, 1) - end - end + describe '#unset_global_setting' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:global, :dummy) + subject.unset_global_setting(:dummy) + end + end - describe '#unset_global_setting' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:global, :dummy) - subject.unset_global_setting(:dummy) - end - end + describe '#route_setting' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:route, :dummy, 1) + subject.route_setting(:dummy, 1) + end - describe '#route_setting' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:route, :dummy, 1) - subject.route_setting(:dummy, 1) - end + it 'sets a value until the next route' do + subject.route_setting :some_thing, :foo_bar + expect(subject.route_setting(:some_thing)).to eq :foo_bar - it 'sets a value until the next route' do - subject.route_setting :some_thing, :foo_bar - expect(subject.route_setting(:some_thing)).to eq :foo_bar + subject.route_end - subject.route_end + expect(subject.route_setting(:some_thing)).to be_nil + end + end - expect(subject.route_setting(:some_thing)).to be_nil - end - end + describe '#unset_route_setting' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:route, :dummy) + subject.unset_route_setting(:dummy) + end + end - describe '#unset_route_setting' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:route, :dummy) - subject.unset_route_setting(:dummy) - end - end + describe '#namespace_setting' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:namespace, :dummy, 1) + subject.namespace_setting(:dummy, 1) + end - describe '#namespace_setting' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:namespace, :dummy, 1) - subject.namespace_setting(:dummy, 1) - end + it 'sets a value until the end of a namespace' do + subject.namespace_start - it 'sets a value until the end of a namespace' do - subject.namespace_start + subject.namespace_setting :some_thing, :foo_bar + expect(subject.namespace_setting(:some_thing)).to eq :foo_bar - subject.namespace_setting :some_thing, :foo_bar - expect(subject.namespace_setting(:some_thing)).to eq :foo_bar + subject.namespace_end - subject.namespace_end + expect(subject.namespace_setting(:some_thing)).to be_nil + end - expect(subject.namespace_setting(:some_thing)).to be_nil - end + it 'resets values after leaving nested namespaces' do + subject.namespace_start - it 'resets values after leaving nested namespaces' do - subject.namespace_start + subject.namespace_setting :some_thing, :foo_bar + expect(subject.namespace_setting(:some_thing)).to eq :foo_bar - subject.namespace_setting :some_thing, :foo_bar - expect(subject.namespace_setting(:some_thing)).to eq :foo_bar + subject.namespace_start - subject.namespace_start + expect(subject.namespace_setting(:some_thing)).to be_nil - expect(subject.namespace_setting(:some_thing)).to be_nil + subject.namespace_end + expect(subject.namespace_setting(:some_thing)).to eq :foo_bar - subject.namespace_end - expect(subject.namespace_setting(:some_thing)).to eq :foo_bar + subject.namespace_end - subject.namespace_end + expect(subject.namespace_setting(:some_thing)).to be_nil + end + end - expect(subject.namespace_setting(:some_thing)).to be_nil - end - end + describe '#unset_namespace_setting' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:namespace, :dummy) + subject.unset_namespace_setting(:dummy) + end + end - describe '#unset_namespace_setting' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:namespace, :dummy) - subject.unset_namespace_setting(:dummy) - end - end + describe '#namespace_inheritable' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:namespace_inheritable, :dummy, 1) + subject.namespace_inheritable(:dummy, 1) + end - describe '#namespace_inheritable' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:namespace_inheritable, :dummy, 1) - subject.namespace_inheritable(:dummy, 1) - end + it 'inherits values from surrounding namespace' do + subject.namespace_start - it 'inherits values from surrounding namespace' do - subject.namespace_start + subject.namespace_inheritable(:some_thing, :foo_bar) + expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar - subject.namespace_inheritable(:some_thing, :foo_bar) - expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar + subject.namespace_start - subject.namespace_start + expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar - expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar + subject.namespace_inheritable(:some_thing, :foo_bar_2) - subject.namespace_inheritable(:some_thing, :foo_bar_2) + expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar_2 - expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar_2 + subject.namespace_end + expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar + subject.namespace_end + end + end - subject.namespace_end - expect(subject.namespace_inheritable(:some_thing)).to eq :foo_bar - subject.namespace_end - end - end + describe '#unset_namespace_inheritable' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:namespace_inheritable, :dummy) + subject.unset_namespace_inheritable(:dummy) + end + end - describe '#unset_namespace_inheritable' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:namespace_inheritable, :dummy) - subject.unset_namespace_inheritable(:dummy) - end - end + describe '#namespace_stackable' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:namespace_stackable, :dummy, 1) + subject.namespace_stackable(:dummy, 1) + end - describe '#namespace_stackable' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:namespace_stackable, :dummy, 1) - subject.namespace_stackable(:dummy, 1) - end + it 'stacks values from surrounding namespace' do + subject.namespace_start - it 'stacks values from surrounding namespace' do - subject.namespace_start + subject.namespace_stackable(:some_thing, :foo_bar) + expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] - subject.namespace_stackable(:some_thing, :foo_bar) - expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] + subject.namespace_start - subject.namespace_start + expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] - expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] + subject.namespace_stackable(:some_thing, :foo_bar_2) - subject.namespace_stackable(:some_thing, :foo_bar_2) + expect(subject.namespace_stackable(:some_thing)).to eq %i[foo_bar foo_bar_2] - expect(subject.namespace_stackable(:some_thing)).to eq %i[foo_bar foo_bar_2] + subject.namespace_end + expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] + subject.namespace_end + end + end - subject.namespace_end - expect(subject.namespace_stackable(:some_thing)).to eq [:foo_bar] - subject.namespace_end - end - end + describe '#unset_namespace_stackable' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:namespace_stackable, :dummy) + subject.unset_namespace_stackable(:dummy) + end + end - describe '#unset_namespace_stackable' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:namespace_stackable, :dummy) - subject.unset_namespace_stackable(:dummy) - end - end + describe '#api_class_setting' do + it 'delegates to get_or_set' do + expect(subject).to receive(:get_or_set).with(:api_class, :dummy, 1) + subject.api_class_setting(:dummy, 1) + end + end + + describe '#unset_api_class_setting' do + it 'delegates to unset' do + expect(subject).to receive(:unset).with(:api_class, :dummy) + subject.unset_api_class_setting(:dummy) + end + end - describe '#api_class_setting' do - it 'delegates to get_or_set' do - expect(subject).to receive(:get_or_set).with(:api_class, :dummy, 1) - subject.api_class_setting(:dummy, 1) - end + describe '#within_namespace' do + it 'calls start and end for a namespace' do + expect(subject).to receive :namespace_start + expect(subject).to receive :namespace_end + + subject.within_namespace do end + end - describe '#unset_api_class_setting' do - it 'delegates to unset' do - expect(subject).to receive(:unset).with(:api_class, :dummy) - subject.unset_api_class_setting(:dummy) - end + it 'returns the last result' do + result = subject.within_namespace do + 1 end - describe '#within_namespace' do - it 'calls start and end for a namespace' do - expect(subject).to receive :namespace_start - expect(subject).to receive :namespace_end + expect(result).to eq 1 + end + end - subject.within_namespace do - end - end + describe 'complex scenario' do + it 'plays well' do + obj1 = dummy_class.new + obj2 = dummy_class.new + obj3 = dummy_class.new - it 'returns the last result' do - result = subject.within_namespace do - 1 - end + obj1_copy = nil + obj2_copy = nil + obj3_copy = nil - expect(result).to eq 1 - end + obj1.within_namespace do + obj1.namespace_stackable(:some_thing, :obj1) + expect(obj1.namespace_stackable(:some_thing)).to eq [:obj1] + obj1_copy = obj1.inheritable_setting.point_in_time_copy end - describe 'complex scenario' do - it 'plays well' do - obj1 = SettingsSpec::Dummy.new - obj2 = SettingsSpec::Dummy.new - obj3 = SettingsSpec::Dummy.new - - obj1_copy = nil - obj2_copy = nil - obj3_copy = nil - - obj1.within_namespace do - obj1.namespace_stackable(:some_thing, :obj1) - expect(obj1.namespace_stackable(:some_thing)).to eq [:obj1] - obj1_copy = obj1.inheritable_setting.point_in_time_copy - end - - expect(obj1.namespace_stackable(:some_thing)).to eq [] - expect(obj1_copy.namespace_stackable[:some_thing]).to eq [:obj1] + expect(obj1.namespace_stackable(:some_thing)).to eq [] + expect(obj1_copy.namespace_stackable[:some_thing]).to eq [:obj1] - obj2.within_namespace do - obj2.namespace_stackable(:some_thing, :obj2) - expect(obj2.namespace_stackable(:some_thing)).to eq [:obj2] - obj2_copy = obj2.inheritable_setting.point_in_time_copy - end + obj2.within_namespace do + obj2.namespace_stackable(:some_thing, :obj2) + expect(obj2.namespace_stackable(:some_thing)).to eq [:obj2] + obj2_copy = obj2.inheritable_setting.point_in_time_copy + end - expect(obj2.namespace_stackable(:some_thing)).to eq [] - expect(obj2_copy.namespace_stackable[:some_thing]).to eq [:obj2] + expect(obj2.namespace_stackable(:some_thing)).to eq [] + expect(obj2_copy.namespace_stackable[:some_thing]).to eq [:obj2] - obj3.within_namespace do - obj3.namespace_stackable(:some_thing, :obj3) - expect(obj3.namespace_stackable(:some_thing)).to eq [:obj3] - obj3_copy = obj3.inheritable_setting.point_in_time_copy - end + obj3.within_namespace do + obj3.namespace_stackable(:some_thing, :obj3) + expect(obj3.namespace_stackable(:some_thing)).to eq [:obj3] + obj3_copy = obj3.inheritable_setting.point_in_time_copy + end - expect(obj3.namespace_stackable(:some_thing)).to eq [] - expect(obj3_copy.namespace_stackable[:some_thing]).to eq [:obj3] + expect(obj3.namespace_stackable(:some_thing)).to eq [] + expect(obj3_copy.namespace_stackable[:some_thing]).to eq [:obj3] - obj1.top_level_setting.inherit_from obj2_copy.point_in_time_copy - obj2.top_level_setting.inherit_from obj3_copy.point_in_time_copy + obj1.top_level_setting.inherit_from obj2_copy.point_in_time_copy + obj2.top_level_setting.inherit_from obj3_copy.point_in_time_copy - expect(obj1_copy.namespace_stackable[:some_thing]).to eq %i[obj3 obj2 obj1] - end - end + expect(obj1_copy.namespace_stackable[:some_thing]).to eq %i[obj3 obj2 obj1] end end end diff --git a/spec/grape/dsl/validations_spec.rb b/spec/grape/dsl/validations_spec.rb deleted file mode 100644 index e39069266..000000000 --- a/spec/grape/dsl/validations_spec.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Grape - module DSL - module ValidationsSpec - class Dummy - include Grape::DSL::Validations - end - end - - describe Validations do - subject { ValidationsSpec::Dummy } - - describe '.reset_validations!' do - before do - subject.namespace_stackable :declared_params, ['dummy'] - subject.namespace_stackable :validations, ['dummy'] - subject.namespace_stackable :params, ['dummy'] - subject.route_setting :description, description: 'lol', params: ['dummy'] - subject.reset_validations! - end - - after do - subject.unset_route_setting :description - end - - it 'resets declared params' do - expect(subject.namespace_stackable(:declared_params)).to eq [] - end - - it 'resets validations' do - expect(subject.namespace_stackable(:validations)).to eq [] - end - - it 'resets params' do - expect(subject.namespace_stackable(:params)).to eq [] - end - - it 'resets documentation params' do - expect(subject.route_setting(:description)[:params]).to be_nil - end - - it 'does not reset documentation description' do - expect(subject.route_setting(:description)[:description]).to eq 'lol' - end - end - - describe '.params' do - it 'returns a ParamsScope' do - expect(subject.params).to be_a Grape::Validations::ParamsScope - end - - it 'evaluates block' do - expect { subject.params { raise 'foo' } }.to raise_error RuntimeError, 'foo' - end - end - - describe '.document_attribute' do - before do - subject.document_attribute([full_name: 'xxx'], foo: 'bar') - end - - it 'creates a param documentation' do - expect(subject.namespace_stackable(:params)).to eq(['xxx' => { foo: 'bar' }]) - expect(subject.route_setting(:description)).to eq(params: { 'xxx' => { foo: 'bar' } }) - end - end - end - end -end diff --git a/spec/grape/endpoint/declared_spec.rb b/spec/grape/endpoint/declared_spec.rb new file mode 100644 index 000000000..538f8a302 --- /dev/null +++ b/spec/grape/endpoint/declared_spec.rb @@ -0,0 +1,881 @@ +# frozen_string_literal: true + +describe Grape::Endpoint do + subject { Class.new(Grape::API) } + + let(:app) { subject } + + describe '#declared' do + before do + subject.format :json + subject.params do + requires :first + optional :second + optional :third, default: 'third-default' + optional :multiple_types, types: [Integer, String] + optional :nested, type: Hash do + optional :fourth + optional :fifth + optional :nested_two, type: Hash do + optional :sixth + optional :nested_three, type: Hash do + optional :seventh + end + end + optional :nested_arr, type: Array do + optional :eighth + end + optional :empty_arr, type: Array + optional :empty_typed_arr, type: Array[String] + optional :empty_hash, type: Hash + optional :empty_set, type: Set + optional :empty_typed_set, type: Set[String] + end + optional :arr, type: Array do + optional :nineth + end + optional :empty_arr, type: Array + optional :empty_typed_arr, type: Array[String] + optional :empty_hash, type: Hash + optional :empty_hash_two, type: Hash + optional :empty_set, type: Set + optional :empty_typed_set, type: Set[String] + end + end + + context 'when params are not built with default class' do + it 'returns an object that corresponds with the params class - hash with indifferent access' do + subject.params do + build_with :hash_with_indifferent_access + end + subject.get '/declared' do + d = declared(params, include_missing: true) + { declared_class: d.class.to_s } + end + + get '/declared?first=present' + expect(JSON.parse(last_response.body)['declared_class']).to eq('ActiveSupport::HashWithIndifferentAccess') + end + + it 'returns an object that corresponds with the params class - hash' do + subject.params do + build_with :hash + end + subject.get '/declared' do + d = declared(params, include_missing: true) + { declared_class: d.class.to_s } + end + + get '/declared?first=present' + expect(JSON.parse(last_response.body)['declared_class']).to eq('Hash') + end + end + + it 'shows nil for nested params if include_missing is true' do + subject.get '/declared' do + declared(params, include_missing: true) + end + + get '/declared?first=present' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['nested']['fourth']).to be_nil + end + + it 'shows nil for multiple allowed types if include_missing is true' do + subject.get '/declared' do + declared(params, include_missing: true) + end + + get '/declared?first=present' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['multiple_types']).to be_nil + end + + it 'does not work in a before filter' do + subject.before do + declared(params) + end + subject.get('/declared') { declared(params) } + + expect { get('/declared') }.to raise_error( + Grape::DSL::InsideRoute::MethodNotYetAvailable + ) + end + + it 'has as many keys as there are declared params' do + subject.get '/declared' do + declared(params) + end + get '/declared?first=present' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body).keys.size).to eq(12) + end + + it 'has a optional param with default value all the time' do + subject.get '/declared' do + declared(params) + end + get '/declared?first=one' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['third']).to eql('third-default') + end + + it 'builds nested params' do + subject.get '/declared' do + declared(params) + end + + get '/declared?first=present&nested[fourth]=1' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['nested'].keys.size).to eq 9 + end + + it 'builds arrays correctly' do + subject.params do + requires :first + optional :second, type: Array + end + subject.post('/declared') { declared(params) } + + post '/declared', first: 'present', second: ['present'] + expect(last_response).to be_created + + body = JSON.parse(last_response.body) + expect(body['second']).to eq(['present']) + end + + it 'builds nested params when given array' do + subject.get '/dummy' do + end + subject.params do + requires :first + optional :second + optional :third, default: 'third-default' + optional :nested, type: Array do + optional :fourth + end + end + subject.get '/declared' do + declared(params) + end + + get '/declared?first=present&nested[][fourth]=1&nested[][fourth]=2' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['nested'].size).to eq 2 + end + + context 'when the param is missing and include_missing=false' do + before do + subject.get('/declared') { declared(params, include_missing: false) } + end + + it 'sets nested objects to be nil' do + get '/declared?first=present' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['nested']).to be_nil + end + end + + context 'when the param is missing and include_missing=true' do + before do + subject.get('/declared') { declared(params, include_missing: true) } + end + + it 'sets objects with type=Hash to be a hash' do + get '/declared?first=present' + expect(last_response).to be_successful + + body = JSON.parse(last_response.body) + expect(body['empty_hash']).to eq({}) + expect(body['empty_hash_two']).to eq({}) + expect(body['nested']).to be_a(Hash) + expect(body['nested']['empty_hash']).to eq({}) + expect(body['nested']['nested_two']).to be_a(Hash) + end + + it 'sets objects with type=Set to be a set' do + get '/declared?first=present' + expect(last_response).to be_successful + + body = JSON.parse(last_response.body) + expect(['#', []]).to include(body['empty_set']) + expect(['#', []]).to include(body['empty_typed_set']) + expect(['#', []]).to include(body['nested']['empty_set']) + expect(['#', []]).to include(body['nested']['empty_typed_set']) + end + + it 'sets objects with type=Array to be an array' do + get '/declared?first=present' + expect(last_response).to be_successful + + body = JSON.parse(last_response.body) + expect(body['empty_arr']).to eq([]) + expect(body['empty_typed_arr']).to eq([]) + expect(body['arr']).to eq([]) + expect(body['nested']['empty_arr']).to eq([]) + expect(body['nested']['empty_typed_arr']).to eq([]) + expect(body['nested']['nested_arr']).to eq([]) + end + + it 'includes all declared children when type=Hash' do + get '/declared?first=present' + expect(last_response).to be_successful + + body = JSON.parse(last_response.body) + expect(body['nested'].keys).to eq(%w[fourth fifth nested_two nested_arr empty_arr empty_typed_arr empty_hash empty_set empty_typed_set]) + expect(body['nested']['nested_two'].keys).to eq(%w[sixth nested_three]) + expect(body['nested']['nested_two']['nested_three'].keys).to eq(%w[seventh]) + end + end + + it 'filters out any additional params that are given' do + subject.get '/declared' do + declared(params) + end + get '/declared?first=one&other=two' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body).key?(:other)).to be false + end + + it 'stringifies if that option is passed' do + subject.get '/declared' do + declared(params, stringify: true) + end + + get '/declared?first=one&other=two' + expect(last_response).to be_successful + expect(JSON.parse(last_response.body)['first']).to eq 'one' + end + + it 'does not include missing attributes if that option is passed' do + subject.get '/declared' do + error! 'expected nil', 400 if declared(params, include_missing: false).key?(:second) + '' + end + + get '/declared?first=one&other=two' + expect(last_response).to be_successful + end + + it 'does not include renamed missing attributes if that option is passed' do + subject.params do + optional :renamed_original, as: :renamed + end + subject.get '/declared' do + error! 'expected nil', 400 if declared(params, include_missing: false).key?(:renamed) + '' + end + + get '/declared?first=one&other=two' + expect(last_response).to be_successful + end + + it 'includes attributes with value that evaluates to false' do + subject.params do + requires :first + optional :boolean + end + + subject.post '/declared' do + error!('expected false', 400) if declared(params, include_missing: false)[:boolean] != false + '' + end + + post '/declared', Grape::Json.dump(first: 'one', boolean: false), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_created + end + + it 'includes attributes with value that evaluates to nil' do + subject.params do + requires :first + optional :second + end + + subject.post '/declared' do + error!('expected nil', 400) unless declared(params, include_missing: false)[:second].nil? + '' + end + + post '/declared', Grape::Json.dump(first: 'one', second: nil), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_created + end + + it 'includes missing attributes with defaults when there are nested hashes' do + subject.get '/dummy' do + end + + subject.params do + requires :first + optional :second + optional :third, default: nil + optional :nested, type: Hash do + optional :fourth, default: nil + optional :fifth, default: nil + requires :nested_nested, type: Hash do + optional :sixth, default: 'sixth-default' + optional :seven, default: nil + end + end + end + + subject.get '/declared' do + declared(params, include_missing: false) + end + + get '/declared?first=present&nested[fourth]=&nested[nested_nested][sixth]=sixth' + json = JSON.parse(last_response.body) + expect(last_response).to be_successful + expect(json['first']).to eq 'present' + expect(json['nested'].keys).to eq %w[fourth fifth nested_nested] + expect(json['nested']['fourth']).to eq '' + expect(json['nested']['nested_nested'].keys).to eq %w[sixth seven] + expect(json['nested']['nested_nested']['sixth']).to eq 'sixth' + end + + it 'does not include missing attributes when there are nested hashes' do + subject.get '/dummy' do + end + + subject.params do + requires :first + optional :second + optional :third + optional :nested, type: Hash do + optional :fourth + optional :fifth + end + end + + subject.get '/declared' do + declared(params, include_missing: false) + end + + get '/declared?first=present&nested[fourth]=4' + json = JSON.parse(last_response.body) + expect(last_response).to be_successful + expect(json['first']).to eq 'present' + expect(json['nested'].keys).to eq %w[fourth] + expect(json['nested']['fourth']).to eq '4' + end + end + + describe '#declared; call from child namespace' do + before do + subject.format :json + subject.namespace :parent do + params do + requires :parent_name, type: String + end + + namespace ':parent_name' do + params do + requires :child_name, type: String + requires :child_age, type: Integer + end + + namespace ':child_name' do + params do + requires :grandchild_name, type: String + end + + get ':grandchild_name' do + { + 'params' => params, + 'without_parent_namespaces' => declared(params, include_parent_namespaces: false), + 'with_parent_namespaces' => declared(params, include_parent_namespaces: true) + } + end + end + end + end + + get '/parent/foo/bar/baz', child_age: 5, extra: 'hello' + end + + let(:parsed_response) { JSON.parse(last_response.body, symbolize_names: true) } + + it { expect(last_response).to be_successful } + + context 'with include_parent_namespaces: false' do + it 'returns declared parameters only from current namespace' do + expect(parsed_response[:without_parent_namespaces]).to eq( + grandchild_name: 'baz' + ) + end + end + + context 'with include_parent_namespaces: true' do + it 'returns declared parameters from every parent namespace' do + expect(parsed_response[:with_parent_namespaces]).to eq( + parent_name: 'foo', + child_name: 'bar', + grandchild_name: 'baz', + child_age: 5 + ) + end + end + + context 'without declaration' do + it 'returns all requested parameters' do + expect(parsed_response[:params]).to eq( + parent_name: 'foo', + child_name: 'bar', + grandchild_name: 'baz', + child_age: 5, + extra: 'hello' + ) + end + end + end + + describe '#declared; from a nested mounted endpoint' do + before do + doubly_mounted = Class.new(Grape::API) + doubly_mounted.namespace :more do + params do + requires :y, type: Integer + end + route_param :y do + get do + { + params: params, + declared_params: declared(params) + } + end + end + end + + mounted = Class.new(Grape::API) + mounted.namespace :another do + params do + requires :mount_space, type: Integer + end + route_param :mount_space do + mount doubly_mounted + end + end + + subject.format :json + subject.namespace :something do + params do + requires :id, type: Integer + end + resource ':id' do + mount mounted + end + end + end + + it 'can access parent attributes' do + get '/something/123/another/456/more/789' + expect(last_response).to be_successful + json = JSON.parse(last_response.body, symbolize_names: true) + + # test all three levels of params + expect(json[:declared_params][:y]).to eq 789 + expect(json[:declared_params][:mount_space]).to eq 456 + expect(json[:declared_params][:id]).to eq 123 + end + end + + describe '#declared; mixed nesting' do + before do + subject.format :json + subject.resource :users do + route_param :id, type: Integer, desc: 'ID desc' do + # Adding this causes route_setting(:declared_params) to be nil for the + # get block in namespace 'foo' below + get do + end + + namespace 'foo' do + get do + { + params: params, + declared_params: declared(params), + declared_params_no_parent: declared(params, include_parent_namespaces: false) + } + end + end + end + end + end + + it 'can access parent route_param' do + get '/users/123/foo', bar: 'bar' + expect(last_response).to be_successful + json = JSON.parse(last_response.body, symbolize_names: true) + + expect(json[:declared_params][:id]).to eq 123 + expect(json[:declared_params_no_parent][:id]).to be_nil + end + end + + describe '#declared; with multiple route_param' do + before do + mounted = Class.new(Grape::API) + mounted.namespace :albums do + get do + declared(params) + end + end + + subject.format :json + subject.namespace :artists do + route_param :id, type: Integer do + get do + declared(params) + end + + params do + requires :filter, type: String + end + get :some_route do + declared(params) + end + end + + route_param :artist_id, type: Integer do + namespace :compositions do + get do + declared(params) + end + end + end + + route_param :compositor_id, type: Integer do + mount mounted + end + end + end + + it 'return only :id without :artist_id' do + get '/artists/1' + json = JSON.parse(last_response.body, symbolize_names: true) + + expect(json).to be_key(:id) + expect(json).not_to be_key(:artist_id) + end + + it 'return only :artist_id without :id' do + get '/artists/1/compositions' + json = JSON.parse(last_response.body, symbolize_names: true) + + expect(json).to be_key(:artist_id) + expect(json).not_to be_key(:id) + end + + it 'return :filter and :id parameters in declared for second enpoint inside route_param' do + get '/artists/1/some_route', filter: 'some_filter' + json = JSON.parse(last_response.body, symbolize_names: true) + + expect(json).to be_key(:filter) + expect(json).to be_key(:id) + expect(json).not_to be_key(:artist_id) + end + + it 'return :compositor_id for mounter in route_param' do + get '/artists/1/albums' + json = JSON.parse(last_response.body, symbolize_names: true) + + expect(json).to be_key(:compositor_id) + expect(json).not_to be_key(:id) + expect(json).not_to be_key(:artist_id) + end + end + + describe 'parameter renaming' do + context 'with a deeply nested parameter structure' do + let(:params) do + { + i_a: 'a', + i_b: { + i_c: 'c', + i_d: { + i_e: { + i_f: 'f', + i_g: 'g', + i_h: [ + { + i_ha: 'ha1', + i_hb: { + i_hc: 'c' + } + }, + { + i_ha: 'ha2', + i_hb: { + i_hc: 'c' + } + } + ] + } + } + } + } + end + let(:declared) do + { + o_a: 'a', + o_b: { + o_c: 'c', + o_d: { + o_e: { + o_f: 'f', + o_g: 'g', + o_h: [ + { + o_ha: 'ha1', + o_hb: { + o_hc: 'c' + } + }, + { + o_ha: 'ha2', + o_hb: { + o_hc: 'c' + } + } + ] + } + } + } + } + end + let(:params_keys) do + [ + 'i_a', + 'i_b', + 'i_b[i_c]', + 'i_b[i_d]', + 'i_b[i_d][i_e]', + 'i_b[i_d][i_e][i_f]', + 'i_b[i_d][i_e][i_g]', + 'i_b[i_d][i_e][i_h]', + 'i_b[i_d][i_e][i_h][i_ha]', + 'i_b[i_d][i_e][i_h][i_hb]', + 'i_b[i_d][i_e][i_h][i_hb][i_hc]' + ] + end + + before do + subject.format :json + subject.params do + optional :i_a, type: String, as: :o_a + optional :i_b, type: Hash, as: :o_b do + optional :i_c, type: String, as: :o_c + optional :i_d, type: Hash, as: :o_d do + optional :i_e, type: Hash, as: :o_e do + optional :i_f, type: String, as: :o_f + optional :i_g, type: String, as: :o_g + optional :i_h, type: Array, as: :o_h do + optional :i_ha, type: String, as: :o_ha + optional :i_hb, type: Hash, as: :o_hb do + optional :i_hc, type: String, as: :o_hc + end + end + end + end + end + end + subject.post '/test' do + declared(params, include_missing: false) + end + subject.post '/test/no-mod' do + before = params.to_h + declared(params, include_missing: false) + after = params.to_h + { before: before, after: after } + end + end + + it 'generates the correct parameter names for documentation' do + expect(subject.routes.first.params.keys).to match(params_keys) + end + + it 'maps the renamed parameter correctly' do + post '/test', **params + expect(JSON.parse(last_response.body, symbolize_names: true)).to \ + match(declared) + end + + it 'maps no parameters when none are given' do + post '/test' + expect(JSON.parse(last_response.body)).to match({}) + end + + it 'does not modify the request params' do + post '/test/no-mod', **params + result = JSON.parse(last_response.body, symbolize_names: true) + expect(result[:before]).to match(result[:after]) + end + end + + context 'with a renamed root parameter' do + before do + subject.format :json + subject.params do + optional :email_address, type: String, regexp: /.+@.+/, as: :email + end + subject.post '/test' do + declared(params, include_missing: false) + end + end + + it 'generates the correct parameter names for documentation' do + expect(subject.routes.first.params.keys).to match(%w[email_address]) + end + + it 'maps the renamed parameter correctly (original name)' do + post '/test', email_address: 'test@example.com' + expect(JSON.parse(last_response.body)).to \ + match('email' => 'test@example.com') + end + + it 'validates the renamed parameter correctly (original name)' do + post '/test', email_address: 'bad[at]example.com' + expect(JSON.parse(last_response.body)).to \ + match('error' => 'email_address is invalid') + end + + it 'ignores the renamed parameter (as name)' do + post '/test', email: 'test@example.com' + expect(JSON.parse(last_response.body)).to match({}) + end + end + + context 'with a renamed hash with nested parameters' do + before do + subject.format :json + subject.params do + optional :address, type: Hash, as: :address_attributes do + optional :street, type: String, values: ['Street 1', 'Street 2'], + default: 'Street 1' + optional :city, type: String + end + end + subject.post '/test' do + declared(params, include_missing: false) + end + end + + it 'generates the correct parameter names for documentation' do + expect(subject.routes.first.params.keys).to \ + match(%w[address address[street] address[city]]) + end + + it 'maps the renamed parameter correctly (original name)' do + post '/test', address: { city: 'Berlin', street: 'Street 2', t: 't' } + expect(JSON.parse(last_response.body)).to \ + match('address_attributes' => { 'city' => 'Berlin', + 'street' => 'Street 2' }) + end + + it 'validates the renamed parameter correctly (original name)' do + post '/test', address: { street: 'unknown' } + expect(JSON.parse(last_response.body)).to \ + match('error' => 'address[street] does not have a valid value') + end + + it 'ignores the renamed parameter (as name)' do + post '/test', address_attributes: { city: 'Berlin', unknown: '1' } + expect(JSON.parse(last_response.body)).to match({}) + end + end + + context 'with a renamed hash with nested renamed parameter' do + before do + subject.format :json + subject.params do + optional :user, type: Hash, as: :user_attributes do + optional :email_address, type: String, regexp: /.+@.+/, as: :email + end + end + subject.post '/test' do + declared(params, include_missing: false) + end + end + + it 'generates the correct parameter names for documentation' do + expect(subject.routes.first.params.keys).to \ + match(%w[user user[email_address]]) + end + + it 'maps the renamed parameter correctly (original name)' do + post '/test', user: { email_address: 'test@example.com' } + expect(JSON.parse(last_response.body)).to \ + match('user_attributes' => { 'email' => 'test@example.com' }) + end + + it 'validates the renamed parameter correctly (original name)' do + post '/test', user: { email_address: 'bad[at]example.com' } + expect(JSON.parse(last_response.body)).to \ + match('error' => 'user[email_address] is invalid') + end + + it 'ignores the renamed parameter (as name, 1)' do + post '/test', user: { email: 'test@example.com' } + expect(JSON.parse(last_response.body)).to \ + match({ 'user_attributes' => {} }) + end + + it 'ignores the renamed parameter (as name, 2)' do + post '/test', user_attributes: { email_address: 'test@example.com' } + expect(JSON.parse(last_response.body)).to match({}) + end + + it 'ignores the renamed parameter (as name, 3)' do + post '/test', user_attributes: { email: 'test@example.com' } + expect(JSON.parse(last_response.body)).to match({}) + end + end + + context 'with a renamed field inside `given` block nested in hashes' do + before do + subject.format :json + subject.params do + requires :a, type: Hash do + optional :c, type: String + given :c do + requires :b, type: Hash do + requires :input_field, as: :output_field + end + end + end + end + subject.post '/test' do + declared(params) + end + end + + it 'renames parameter input_field to output_field' do + post '/test', { a: { b: { input_field: 'value' }, c: 'value2' } } + + expect(JSON.parse(last_response.body)).to \ + match('a' => { 'b' => { 'output_field' => 'value' }, 'c' => 'value2' }) + end + end + end + + describe 'optional_array' do + subject { last_response } + + let(:app) do + Class.new(Grape::API) do + params do + requires :z, type: Array do + optional :a, type: Integer + end + end + + post do + declared(params, include_missing: false) + end + end + end + + before { post '/', { z: [] } } + + it { is_expected.to be_successful } + end +end diff --git a/spec/grape/endpoint_spec.rb b/spec/grape/endpoint_spec.rb index a45abe36d..3f98aacb6 100644 --- a/spec/grape/endpoint_spec.rb +++ b/spec/grape/endpoint_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Endpoint do subject { Class.new(Grape::API) } @@ -10,32 +8,32 @@ def app end describe '.before_each' do - after { Grape::Endpoint.before_each.clear } + after { described_class.before_each.clear } it 'is settable via block' do block = ->(_endpoint) { 'noop' } - Grape::Endpoint.before_each(&block) - expect(Grape::Endpoint.before_each.first).to eq(block) + described_class.before_each(&block) + expect(described_class.before_each.first).to eq(block) end it 'is settable via reference' do block = ->(_endpoint) { 'noop' } - Grape::Endpoint.before_each block - expect(Grape::Endpoint.before_each.first).to eq(block) + described_class.before_each block + expect(described_class.before_each.first).to eq(block) end it 'is able to override a helper' do subject.get('/') { current_user } expect { get '/' }.to raise_error(NameError) - Grape::Endpoint.before_each do |endpoint| + described_class.before_each do |endpoint| allow(endpoint).to receive(:current_user).and_return('Bob') end get '/' expect(last_response.body).to eq('Bob') - Grape::Endpoint.before_each(nil) + described_class.before_each(nil) expect { get '/' }.to raise_error(NameError) end @@ -46,18 +44,18 @@ def app end expect { get '/' }.to raise_error(NameError) - Grape::Endpoint.before_each do |endpoint| + described_class.before_each do |endpoint| allow(endpoint).to receive(:current_user).and_return('Bob') end - Grape::Endpoint.before_each do |endpoint| + described_class.before_each do |endpoint| allow(endpoint).to receive(:authenticate_user!).and_return(true) end get '/' expect(last_response.body).to eq('Bob') - Grape::Endpoint.before_each(nil) + described_class.before_each(nil) expect { get '/' }.to raise_error(NameError) end end @@ -66,7 +64,7 @@ def app it 'takes a settings stack, options, and a block' do p = proc {} expect do - Grape::Endpoint.new(Grape::Util::InheritableSetting.new, { + described_class.new(Grape::Util::InheritableSetting.new, { path: '/', method: :get }, &p) @@ -77,7 +75,7 @@ def app it 'sets itself in the env upon call' do subject.get('/') { 'Hello world.' } get '/' - expect(last_request.env['api.endpoint']).to be_kind_of(Grape::Endpoint) + expect(last_request.env[Grape::Env::API_ENDPOINT]).to be_a(described_class) end describe '#status' do @@ -117,6 +115,58 @@ def app expect(memoized_status).to eq(201) expect(last_response.body).to eq('Hello') end + + context 'when rescue_from' do + subject { last_request.env[Grape::Env::API_ENDPOINT].status } + + before do + post '/' + end + + context 'when :all blockless' do + context 'when default_error_status is not set' do + let(:app) do + Class.new(Grape::API) do + rescue_from :all + + post { raise StandardError } + end + end + + it { is_expected.to eq(last_response.status) } + end + + context 'when default_error_status is set' do + let(:app) do + Class.new(Grape::API) do + default_error_status 418 + rescue_from :all + + post { raise StandardError } + end + end + + it { is_expected.to eq(last_response.status) } + end + end + + context 'when :with' do + let(:app) do + Class.new(Grape::API) do + helpers do + def handle_argument_error + error!("I'm a teapot!", 418) + end + end + rescue_from ArgumentError, with: :handle_argument_error + + post { raise ArgumentError } + end + end + + it { is_expected.to eq(last_response.status) } + end + end end describe '#header' do @@ -137,22 +187,23 @@ def app headers.to_json end end + + let(:headers) do + Grape::Util::Header.new.tap do |h| + h['Cookie'] = '' + h['Host'] = 'example.org' + end + end + it 'includes request headers' do get '/headers' - expect(JSON.parse(last_response.body)).to eq( - 'Host' => 'example.org', - 'Cookie' => '' - ) + expect(JSON.parse(last_response.body)).to include(headers.to_h) end + it 'includes additional request headers' do get '/headers', nil, 'HTTP_X_GRAPE_CLIENT' => '1' - expect(JSON.parse(last_response.body)['X-Grape-Client']).to eq('1') - end - it 'includes headers passed as symbols' do - env = Rack::MockRequest.env_for('/headers') - env['HTTP_SYMBOL_HEADER'.to_sym] = 'Goliath passes symbols' - body = read_chunks(subject.call(env)[2]).join - expect(JSON.parse(body)['Symbol-Header']).to eq('Goliath passes symbols') + x_grape_client_header = 'x-grape-client' + expect(JSON.parse(last_response.body)[x_grape_client_header]).to eq('1') end end @@ -172,36 +223,42 @@ def app get('/get/cookies') - expect(last_response.headers['Set-Cookie'].split("\n").sort).to eql [ - 'cookie3=symbol', - 'cookie4=secret+code+here', - 'my-awesome-cookie1=is+cool', - 'my-awesome-cookie2=is+cool+too; domain=my.example.com; path=/; secure' - ] + expect(last_response.cookie_jar).to contain_exactly( + { 'name' => 'cookie3', 'value' => 'symbol' }, + { 'name' => 'cookie4', 'value' => 'secret code here' }, + { 'name' => 'my-awesome-cookie1', 'value' => 'is cool' }, + { 'name' => 'my-awesome-cookie2', 'value' => 'is cool too', 'domain' => 'my.example.com', 'path' => '/', 'secure' => true } + ) end it 'sets browser cookies and does not set response cookies' do + set_cookie %w[username=mrplum sandbox=true] subject.get('/username') do cookies[:username] end - get('/username', {}, 'HTTP_COOKIE' => 'username=mrplum; sandbox=true') + get '/username' expect(last_response.body).to eq('mrplum') - expect(last_response.headers['Set-Cookie']).to be_nil + expect(last_response.cookie_jar).to be_empty end it 'sets and update browser cookies' do + set_cookie %w[username=user sandbox=false] subject.get('/username') do cookies[:sandbox] = true if cookies[:sandbox] == 'false' cookies[:username] += '_test' end - get('/username', {}, 'HTTP_COOKIE' => 'username=user; sandbox=false') + + get '/username' expect(last_response.body).to eq('user_test') - expect(last_response.headers['Set-Cookie']).to match(/username=user_test/) - expect(last_response.headers['Set-Cookie']).to match(/sandbox=true/) + expect(last_response.cookie_jar).to contain_exactly( + { 'name' => 'sandbox', 'value' => 'true' }, + { 'name' => 'username', 'value' => 'user_test' } + ) end it 'deletes cookie' do + set_cookie %w[delete_this_cookie=1 and_this=2] subject.get('/test') do sum = 0 cookies.each do |name, val| @@ -210,22 +267,16 @@ def app end sum end - get '/test', {}, 'HTTP_COOKIE' => 'delete_this_cookie=1; and_this=2' + get '/test' expect(last_response.body).to eq('3') - cookies = Hash[last_response.headers['Set-Cookie'].split("\n").map do |set_cookie| - cookie = CookieJar::Cookie.from_set_cookie 'http://localhost/test', set_cookie - [cookie.name, cookie] - end] - expect(cookies.size).to eq(2) - %w[and_this delete_this_cookie].each do |cookie_name| - cookie = cookies[cookie_name] - expect(cookie).not_to be_nil - expect(cookie.value).to eq('deleted') - expect(cookie.expired?).to be true - end + expect(last_response.cookie_jar).to contain_exactly( + { 'name' => 'and_this', 'value' => '', 'max-age' => 0, 'expires' => Time.at(0) }, + { 'name' => 'delete_this_cookie', 'value' => '', 'max-age' => 0, 'expires' => Time.at(0) } + ) end it 'deletes cookies with path' do + set_cookie %w[delete_this_cookie=1 and_this=2] subject.get('/test') do sum = 0 cookies.each do |name, val| @@ -234,26 +285,18 @@ def app end sum end - get('/test', {}, 'HTTP_COOKIE' => 'delete_this_cookie=1; and_this=2') + get '/test' expect(last_response.body).to eq('3') - cookies = Hash[last_response.headers['Set-Cookie'].split("\n").map do |set_cookie| - cookie = CookieJar::Cookie.from_set_cookie 'http://localhost/test', set_cookie - [cookie.name, cookie] - end] - expect(cookies.size).to eq(2) - %w[and_this delete_this_cookie].each do |cookie_name| - cookie = cookies[cookie_name] - expect(cookie).not_to be_nil - expect(cookie.value).to eq('deleted') - expect(cookie.path).to eq('/test') - expect(cookie.expired?).to be true - end + expect(last_response.cookie_jar).to contain_exactly( + { 'name' => 'and_this', 'path' => '/test', 'value' => '', 'max-age' => 0, 'expires' => Time.at(0) }, + { 'name' => 'delete_this_cookie', 'path' => '/test', 'value' => '', 'max-age' => 0, 'expires' => Time.at(0) } + ) end end describe '#params' do context 'default class' do - it 'should be a ActiveSupport::HashWithIndifferentAccess' do + it 'is a ActiveSupport::HashWithIndifferentAccess' do subject.get '/foo' do params.class end @@ -280,540 +323,6 @@ def app end end - describe '#declared' do - before do - subject.format :json - subject.params do - requires :first - optional :second - optional :third, default: 'third-default' - optional :nested, type: Hash do - optional :fourth - optional :fifth - optional :nested_two, type: Hash do - optional :sixth - optional :nested_three, type: Hash do - optional :seventh - end - end - optional :nested_arr, type: Array do - optional :eighth - end - end - optional :arr, type: Array do - optional :nineth - end - end - end - - context 'when params are not built with default class' do - it 'returns an object that corresponds with the params class - hash with indifferent access' do - subject.params do - build_with Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder - end - subject.get '/declared' do - d = declared(params, include_missing: true) - { declared_class: d.class.to_s } - end - - get '/declared?first=present' - expect(JSON.parse(last_response.body)['declared_class']).to eq('ActiveSupport::HashWithIndifferentAccess') - end - - it 'returns an object that corresponds with the params class - hashie mash' do - subject.params do - build_with Grape::Extensions::Hashie::Mash::ParamBuilder - end - subject.get '/declared' do - d = declared(params, include_missing: true) - { declared_class: d.class.to_s } - end - - get '/declared?first=present' - expect(JSON.parse(last_response.body)['declared_class']).to eq('Hashie::Mash') - end - - it 'returns an object that corresponds with the params class - hash' do - subject.params do - build_with Grape::Extensions::Hash::ParamBuilder - end - subject.get '/declared' do - d = declared(params, include_missing: true) - { declared_class: d.class.to_s } - end - - get '/declared?first=present' - expect(JSON.parse(last_response.body)['declared_class']).to eq('Hash') - end - end - - it 'should show nil for nested params if include_missing is true' do - subject.get '/declared' do - declared(params, include_missing: true) - end - - get '/declared?first=present' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested']['fourth']).to be_nil - end - - it 'does not work in a before filter' do - subject.before do - declared(params) - end - subject.get('/declared') { declared(params) } - - expect { get('/declared') }.to raise_error( - Grape::DSL::InsideRoute::MethodNotYetAvailable - ) - end - - it 'has as many keys as there are declared params' do - subject.get '/declared' do - declared(params) - end - get '/declared?first=present' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body).keys.size).to eq(5) - end - - it 'has a optional param with default value all the time' do - subject.get '/declared' do - declared(params) - end - get '/declared?first=one' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['third']).to eql('third-default') - end - - it 'builds nested params' do - subject.get '/declared' do - declared(params) - end - - get '/declared?first=present&nested[fourth]=1' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested'].keys.size).to eq 4 - end - - it 'builds nested params when given array' do - subject.get '/dummy' do - end - subject.params do - requires :first - optional :second - optional :third, default: 'third-default' - optional :nested, type: Array do - optional :fourth - end - end - subject.get '/declared' do - declared(params) - end - - get '/declared?first=present&nested[][fourth]=1&nested[][fourth]=2' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested'].size).to eq 2 - end - - context 'sets nested objects when the param is missing' do - it 'to be a hash when include_missing is true' do - subject.get '/declared' do - declared(params, include_missing: true) - end - - get '/declared?first=present' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested']).to eq({}) - end - - it 'to be an array when include_missing is true' do - subject.get '/declared' do - declared(params, include_missing: true) - end - - get '/declared?first=present' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['arr']).to be_a(Array) - end - - it 'to be an array when nested and include_missing is true' do - subject.get '/declared' do - declared(params, include_missing: true) - end - - get '/declared?first=present&nested[fourth]=1' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested']['nested_arr']).to be_a(Array) - end - - it 'to be nil when include_missing is false' do - subject.get '/declared' do - declared(params, include_missing: false) - end - - get '/declared?first=present' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['nested']).to be_nil - end - end - - it 'filters out any additional params that are given' do - subject.get '/declared' do - declared(params) - end - get '/declared?first=one&other=two' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body).key?(:other)).to eq false - end - - it 'stringifies if that option is passed' do - subject.get '/declared' do - declared(params, stringify: true) - end - - get '/declared?first=one&other=two' - expect(last_response.status).to eq(200) - expect(JSON.parse(last_response.body)['first']).to eq 'one' - end - - it 'does not include missing attributes if that option is passed' do - subject.get '/declared' do - error! 'expected nil', 400 if declared(params, include_missing: false).key?(:second) - '' - end - - get '/declared?first=one&other=two' - expect(last_response.status).to eq(200) - end - - it 'does not include renamed missing attributes if that option is passed' do - subject.params do - optional :renamed_original, as: :renamed - end - subject.get '/declared' do - error! 'expected nil', 400 if declared(params, include_missing: false).key?(:renamed) - '' - end - - get '/declared?first=one&other=two' - expect(last_response.status).to eq(200) - end - - it 'includes attributes with value that evaluates to false' do - subject.params do - requires :first - optional :boolean - end - - subject.post '/declared' do - error!('expected false', 400) if declared(params, include_missing: false)[:boolean] != false - '' - end - - post '/declared', ::Grape::Json.dump(first: 'one', boolean: false), 'CONTENT_TYPE' => 'application/json' - expect(last_response.status).to eq(201) - end - - it 'includes attributes with value that evaluates to nil' do - subject.params do - requires :first - optional :second - end - - subject.post '/declared' do - error!('expected nil', 400) unless declared(params, include_missing: false)[:second].nil? - '' - end - - post '/declared', ::Grape::Json.dump(first: 'one', second: nil), 'CONTENT_TYPE' => 'application/json' - expect(last_response.status).to eq(201) - end - - it 'includes missing attributes with defaults when there are nested hashes' do - subject.get '/dummy' do - end - - subject.params do - requires :first - optional :second - optional :third, default: nil - optional :nested, type: Hash do - optional :fourth, default: nil - optional :fifth, default: nil - requires :nested_nested, type: Hash do - optional :sixth, default: 'sixth-default' - optional :seven, default: nil - end - end - end - - subject.get '/declared' do - declared(params, include_missing: false) - end - - get '/declared?first=present&nested[fourth]=&nested[nested_nested][sixth]=sixth' - json = JSON.parse(last_response.body) - expect(last_response.status).to eq(200) - expect(json['first']).to eq 'present' - expect(json['nested'].keys).to eq %w[fourth fifth nested_nested] - expect(json['nested']['fourth']).to eq '' - expect(json['nested']['nested_nested'].keys).to eq %w[sixth seven] - expect(json['nested']['nested_nested']['sixth']).to eq 'sixth' - end - - it 'does not include missing attributes when there are nested hashes' do - subject.get '/dummy' do - end - - subject.params do - requires :first - optional :second - optional :third - optional :nested, type: Hash do - optional :fourth - optional :fifth - end - end - - subject.get '/declared' do - declared(params, include_missing: false) - end - - get '/declared?first=present&nested[fourth]=4' - json = JSON.parse(last_response.body) - expect(last_response.status).to eq(200) - expect(json['first']).to eq 'present' - expect(json['nested'].keys).to eq %w[fourth] - expect(json['nested']['fourth']).to eq '4' - end - end - - describe '#declared; call from child namespace' do - before do - subject.format :json - subject.namespace :parent do - params do - requires :parent_name, type: String - end - - namespace ':parent_name' do - params do - requires :child_name, type: String - requires :child_age, type: Integer - end - - namespace ':child_name' do - params do - requires :grandchild_name, type: String - end - - get ':grandchild_name' do - { - 'params' => params, - 'without_parent_namespaces' => declared(params, include_parent_namespaces: false), - 'with_parent_namespaces' => declared(params, include_parent_namespaces: true) - } - end - end - end - end - - get '/parent/foo/bar/baz', child_age: 5, extra: 'hello' - end - - let(:parsed_response) { JSON.parse(last_response.body, symbolize_names: true) } - - it { expect(last_response.status).to eq 200 } - - context 'with include_parent_namespaces: false' do - it 'returns declared parameters only from current namespace' do - expect(parsed_response[:without_parent_namespaces]).to eq( - grandchild_name: 'baz' - ) - end - end - - context 'with include_parent_namespaces: true' do - it 'returns declared parameters from every parent namespace' do - expect(parsed_response[:with_parent_namespaces]).to eq( - parent_name: 'foo', - child_name: 'bar', - grandchild_name: 'baz', - child_age: 5 - ) - end - end - - context 'without declaration' do - it 'returns all requested parameters' do - expect(parsed_response[:params]).to eq( - parent_name: 'foo', - child_name: 'bar', - grandchild_name: 'baz', - child_age: 5, - extra: 'hello' - ) - end - end - end - - describe '#declared; from a nested mounted endpoint' do - before do - doubly_mounted = Class.new(Grape::API) - doubly_mounted.namespace :more do - params do - requires :y, type: Integer - end - route_param :y do - get do - { - params: params, - declared_params: declared(params) - } - end - end - end - - mounted = Class.new(Grape::API) - mounted.namespace :another do - params do - requires :mount_space, type: Integer - end - route_param :mount_space do - mount doubly_mounted - end - end - - subject.format :json - subject.namespace :something do - params do - requires :id, type: Integer - end - resource ':id' do - mount mounted - end - end - end - - it 'can access parent attributes' do - get '/something/123/another/456/more/789' - expect(last_response.status).to eq 200 - json = JSON.parse(last_response.body, symbolize_names: true) - - # test all three levels of params - expect(json[:declared_params][:y]).to eq 789 - expect(json[:declared_params][:mount_space]).to eq 456 - expect(json[:declared_params][:id]).to eq 123 - end - end - - describe '#declared; mixed nesting' do - before do - subject.format :json - subject.resource :users do - route_param :id, type: Integer, desc: 'ID desc' do - # Adding this causes route_setting(:declared_params) to be nil for the - # get block in namespace 'foo' below - get do - end - - namespace 'foo' do - get do - { - params: params, - declared_params: declared(params), - declared_params_no_parent: declared(params, include_parent_namespaces: false) - } - end - end - end - end - end - - it 'can access parent route_param' do - get '/users/123/foo', bar: 'bar' - expect(last_response.status).to eq 200 - json = JSON.parse(last_response.body, symbolize_names: true) - - expect(json[:declared_params][:id]).to eq 123 - expect(json[:declared_params_no_parent][:id]).to eq nil - end - end - - describe '#declared; with multiple route_param' do - before do - mounted = Class.new(Grape::API) - mounted.namespace :albums do - get do - declared(params) - end - end - - subject.format :json - subject.namespace :artists do - route_param :id, type: Integer do - get do - declared(params) - end - - params do - requires :filter, type: String - end - get :some_route do - declared(params) - end - end - - route_param :artist_id, type: Integer do - namespace :compositions do - get do - declared(params) - end - end - end - - route_param :compositor_id, type: Integer do - mount mounted - end - end - end - - it 'return only :id without :artist_id' do - get '/artists/1' - json = JSON.parse(last_response.body, symbolize_names: true) - - expect(json.key?(:id)).to be_truthy - expect(json.key?(:artist_id)).not_to be_truthy - end - - it 'return only :artist_id without :id' do - get '/artists/1/compositions' - json = JSON.parse(last_response.body, symbolize_names: true) - - expect(json.key?(:artist_id)).to be_truthy - expect(json.key?(:id)).not_to be_truthy - end - - it 'return :filter and :id parameters in declared for second enpoint inside route_param' do - get '/artists/1/some_route', filter: 'some_filter' - json = JSON.parse(last_response.body, symbolize_names: true) - - expect(json.key?(:filter)).to be_truthy - expect(json.key?(:id)).to be_truthy - expect(json.key?(:artist_id)).not_to be_truthy - end - - it 'return :compositor_id for mounter in route_param' do - get '/artists/1/albums' - json = JSON.parse(last_response.body, symbolize_names: true) - - expect(json.key?(:compositor_id)).to be_truthy - expect(json.key?(:id)).not_to be_truthy - expect(json.key?(:artist_id)).not_to be_truthy - end - end - describe '#params' do it 'is available to the caller' do subject.get('/hey') do @@ -873,7 +382,7 @@ def app end context 'namespace requirements' do - before :each do + before do subject.namespace :outer, requirements: { person_email: /abc@(.*).com/ } do get('/:person_email') do params[:person_email] @@ -892,7 +401,7 @@ def app expect(last_response.body).to eq('abc@example.com') end - it "should override outer namespace's requirements" do + it "overrides outer namespace's requirements" do get '/outer/inner/someone@testing.wrong/test/1' expect(last_response.status).to eq(404) @@ -904,7 +413,7 @@ def app end context 'from body parameters' do - before(:each) do + before do subject.post '/request_body' do params[:user] end @@ -914,7 +423,7 @@ def app end it 'converts JSON bodies to params' do - post '/request_body', ::Grape::Json.dump(user: 'Bobby T.'), 'CONTENT_TYPE' => 'application/json' + post '/request_body', Grape::Json.dump(user: 'Bobby T.'), 'CONTENT_TYPE' => 'application/json' expect(last_response.body).to eq('Bobby T.') end @@ -934,14 +443,16 @@ def app expect(last_response.body).to eq('Bobby T.') end else + let(:body) { 'Bobby T.' } + it 'converts XML bodies to params' do - post '/request_body', 'Bobby T.', 'CONTENT_TYPE' => 'application/xml' - expect(last_response.body).to eq('{"__content__"=>"Bobby T."}') + post '/request_body', body, 'CONTENT_TYPE' => 'application/xml' + expect(last_response.body).to eq(Grape::Xml.parse(body)['user'].to_s) end it 'converts XML bodies to params' do - put '/request_body', 'Bobby T.', 'CONTENT_TYPE' => 'application/xml' - expect(last_response.body).to eq('{"__content__"=>"Bobby T."}') + put '/request_body', body, 'CONTENT_TYPE' => 'application/xml' + expect(last_response.body).to eq(Grape::Xml.parse(body)['user'].to_s) end end @@ -950,10 +461,44 @@ def app error! 400, 'expected nil' if params[:version] params[:user] end - post '/omitted_params', ::Grape::Json.dump(user: 'Bob'), 'CONTENT_TYPE' => 'application/json' + post '/omitted_params', Grape::Json.dump(user: 'Bob'), 'CONTENT_TYPE' => 'application/json' expect(last_response.status).to eq(201) expect(last_response.body).to eq('Bob') end + + # Rack swallowed this error until v2.2.0 + it 'returns a 400 if given an invalid multipart body', if: Gem::Version.new(Rack.release) >= Gem::Version.new('2.2.0') do + subject.params do + requires :file, type: Rack::Multipart::UploadedFile + end + subject.post '/upload' do + params[:file][:filename] + end + post '/upload', { file: '' }, 'CONTENT_TYPE' => 'multipart/form-data; boundary=foobar' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('file is invalid') + end + end + + context 'when the limit on multipart files is exceeded' do + around do |example| + limit = Rack::Utils.multipart_part_limit + Rack::Utils.multipart_part_limit = 1 + example.run + Rack::Utils.multipart_part_limit = limit + end + + it 'returns a 413 if given too many multipart files' do + subject.params do + requires :file, type: Rack::Multipart::UploadedFile + end + subject.post '/upload' do + params[:file][:filename] + end + post '/upload', { file: Rack::Test::UploadedFile.new(__FILE__, 'text/plain'), extra: Rack::Test::UploadedFile.new(__FILE__, 'text/plain') } + expect(last_response.status).to eq(413) + expect(last_response.body).to eq("the number of uploaded files exceeded the system's configured limit (1)") + end end it 'responds with a 415 for an unsupported content-type' do @@ -973,7 +518,7 @@ def app subject.put '/request_body' do params[:user] end - put '/request_body', ::Grape::Json.dump(user: 'Bob'), 'CONTENT_TYPE' => 'text/plain' + put '/request_body', Grape::Json.dump(user: 'Bob'), 'CONTENT_TYPE' => 'text/plain' expect(last_response.status).to eq(415) expect(last_response.body).to eq('{"error":"The provided content-type \'text/plain\' is not supported."}') @@ -987,15 +532,15 @@ def app subject.post do params[:data] end - post '/', ::Grape::Json.dump(data: { some: 'payload' }), 'CONTENT_TYPE' => 'application/json' + post '/', Grape::Json.dump(data: { some: 'payload' }), 'CONTENT_TYPE' => 'application/json' end - it 'should not response with 406 for same type without params' do + it 'does not response with 406 for same type without params' do expect(last_response.status).not_to be 406 end - it 'should response with given content type in headers' do - expect(last_response.headers['Content-Type']).to eq 'application/json; charset=utf-8' + it 'responses with given content type in headers' do + expect(last_response.content_type).to eq 'application/json; charset=utf-8' end end @@ -1155,7 +700,7 @@ def app end get '/hey' expect(last_response.status).to eq 302 - expect(last_response.headers['Location']).to eq '/ha' + expect(last_response.location).to eq '/ha' expect(last_response.body).to eq 'This resource has been moved temporarily to /ha.' end @@ -1163,9 +708,9 @@ def app subject.post('/hey') do redirect '/ha' end - post '/hey', {}, 'HTTP_VERSION' => 'HTTP/1.1' + post '/hey', {}, 'HTTP_VERSION' => 'HTTP/1.1', 'SERVER_PROTOCOL' => 'HTTP/1.1' expect(last_response.status).to eq 303 - expect(last_response.headers['Location']).to eq '/ha' + expect(last_response.location).to eq '/ha' expect(last_response.body).to eq 'An alternate resource is located at /ha.' end @@ -1175,7 +720,7 @@ def app end get '/hey' expect(last_response.status).to eq 301 - expect(last_response.headers['Location']).to eq '/ha' + expect(last_response.location).to eq '/ha' expect(last_response.body).to eq 'This resource has been moved permanently to /ha.' end @@ -1188,6 +733,24 @@ def app end end + describe 'NameError' do + context 'when referencing an undefined local variable or method' do + let(:error_message) do + if Gem::Version.new(RUBY_VERSION).release <= Gem::Version.new('3.2') + %r{undefined local variable or method `undefined_helper' for # in '/hey' endpoint} + else + opening_quote = Gem::Version.new(RUBY_VERSION).release >= Gem::Version.new('3.4') ? "'" : '`' + /undefined local variable or method #{opening_quote}undefined_helper' for/ + end + end + + it 'raises NameError but stripping the internals of the Grape::Endpoint class and including the API route' do + subject.get('/hey') { undefined_helper } + expect { get '/hey' }.to raise_error(NameError, error_message) + end + end + end + it 'does not persist params between calls' do subject.post('/new') do params[:text] @@ -1230,16 +793,18 @@ def memoized describe '.generate_api_method' do it 'raises NameError if the method name is already in use' do expect do - Grape::Endpoint.generate_api_method('version', &proc {}) + described_class.generate_api_method('version', &proc {}) end.to raise_error(NameError) end + it 'raises ArgumentError if a block is not given' do expect do - Grape::Endpoint.generate_api_method('GET without a block method') + described_class.generate_api_method('GET without a block method') end.to raise_error(ArgumentError) end + it 'returns a Proc' do - expect(Grape::Endpoint.generate_api_method('GET test for a proc', &proc {})).to be_a Proc + expect(described_class.generate_api_method('GET test for a proc', &proc {})).to be_a Proc end end @@ -1298,7 +863,7 @@ def memoized end get '/error_filters' - expect(last_response.status).to eql 500 + expect(last_response.status).to be 500 expect(called).to match_array %w[before before_validation] end @@ -1307,8 +872,11 @@ def memoized subject.before { called << 'parent' } subject.namespace :parent do before { called << 'prior' } + before { error! :oops, 500 } + before { called << 'subsequent' } + get :hello do called << :endpoint 'Hello!' @@ -1316,7 +884,7 @@ def memoized end get '/parent/hello' - expect(last_response.status).to eql 500 + expect(last_response.status).to be 500 expect(called).to match_array %w[parent prior] end end @@ -1325,33 +893,33 @@ def memoized context 'anchoring' do describe 'delete 204' do it 'allows for the anchoring option with a delete method' do - subject.send(:delete, '/example', anchor: true) {} - send(:delete, '/example/and/some/more') - expect(last_response.status).to eql 404 + subject.delete('/example', anchor: true) + delete '/example/and/some/more' + expect(last_response).to be_not_found end it 'anchors paths by default for the delete method' do - subject.send(:delete, '/example') {} - send(:delete, '/example/and/some/more') - expect(last_response.status).to eql 404 + subject.delete '/example' + delete '/example/and/some/more' + expect(last_response).to be_not_found end it 'responds to /example/and/some/more for the non-anchored delete method' do - subject.send(:delete, '/example', anchor: false) {} - send(:delete, '/example/and/some/more') - expect(last_response.status).to eql 204 + subject.delete '/example', anchor: false + delete '/example/and/some/more' + expect(last_response).to be_no_content expect(last_response.body).to be_empty end end describe 'delete 200, with response body' do it 'responds to /example/and/some/more for the non-anchored delete method' do - subject.send(:delete, '/example', anchor: false) do + subject.delete('/example', anchor: false) do status 200 body 'deleted' end - send(:delete, '/example/and/some/more') - expect(last_response.status).to eql 200 + delete '/example/and/some/more' + expect(last_response).to be_successful expect(last_response.body).not_to be_empty end end @@ -1360,7 +928,7 @@ def memoized it 'responds to /example delete method' do subject.delete(:example) { 'deleted' } delete '/example' - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 expect(last_response.body).not_to be_empty end end @@ -1369,7 +937,7 @@ def memoized it 'responds to /example delete method' do subject.delete(:example) { nil } delete '/example' - expect(last_response.status).to eql 204 + expect(last_response.status).to be 204 expect(last_response.body).to be_empty end end @@ -1378,7 +946,7 @@ def memoized it 'responds to /example delete method' do subject.delete(:example) { '' } delete '/example' - expect(last_response.status).to eql 204 + expect(last_response.status).to be 204 expect(last_response.body).to be_empty end end @@ -1390,7 +958,7 @@ def memoized verb end send(verb, '/example/and/some/more') - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 end it "anchors paths by default for the #{verb.upcase} method" do @@ -1398,7 +966,7 @@ def memoized verb end send(verb, '/example/and/some/more') - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 end it "responds to /example/and/some/more for the non-anchored #{verb.upcase} method" do @@ -1421,8 +989,9 @@ def memoized get '/url' expect(last_response.body).to eq('http://example.org/url') end + ['v1', :v1].each do |version| - it "should include version #{version}" do + it "includes version #{version}" do subject.version version, using: :path subject.get('/url') do request.url @@ -1431,7 +1000,7 @@ def memoized expect(last_response.body).to eq("http://example.org/#{version}/url") end end - it 'should include prefix' do + it 'includes prefix' do subject.version 'v1', using: :path subject.prefix 'api' subject.get('/url') do @@ -1456,7 +1025,7 @@ def memoized expect(last_response.status).to eq(406) end - it 'result in a 406 response if they cannot be parsed by rack-accept' do + it 'result in a 406 response if they cannot be parsed' do get '/test', {}, 'HTTP_ACCEPT' => 'application/vnd.ohanapi.v1+json; version=1' expect(last_response.status).to eq(406) end @@ -1465,7 +1034,7 @@ def memoized context 'binary' do before do subject.get do - file FileStreamer.new(__FILE__) + stream FileStreamer.new(__FILE__) end end @@ -1521,26 +1090,26 @@ def memoized # In order that the events finalized (time each block ended) expect(@events).to contain_exactly( - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: a_collection_containing_exactly(an_instance_of(Proc)), type: :before }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :before_validation }), - have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(described_class), validators: [], request: a_kind_of(Grape::Request) }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :after_validation }), - have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(Grape::Endpoint) }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :after }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :finally }), - have_attributes(name: 'endpoint_run.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run.grape', payload: { endpoint: a_kind_of(described_class), env: an_instance_of(Hash) }), have_attributes(name: 'format_response.grape', payload: { env: an_instance_of(Hash), formatter: a_kind_of(Module) }) @@ -1548,25 +1117,25 @@ def memoized # In order that events were initialized expect(@events.sort_by(&:time)).to contain_exactly( - have_attributes(name: 'endpoint_run.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run.grape', payload: { endpoint: a_kind_of(described_class), env: an_instance_of(Hash) }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: a_collection_containing_exactly(an_instance_of(Proc)), type: :before }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :before_validation }), - have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_validators.grape', payload: { endpoint: a_kind_of(described_class), validators: [], request: a_kind_of(Grape::Request) }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :after_validation }), - have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(Grape::Endpoint) }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_render.grape', payload: { endpoint: a_kind_of(described_class) }), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :after }), - have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(Grape::Endpoint), + have_attributes(name: 'endpoint_run_filters.grape', payload: { endpoint: a_kind_of(described_class), filters: [], type: :finally }), have_attributes(name: 'format_response.grape', payload: { env: an_instance_of(Hash), @@ -1574,4 +1143,24 @@ def memoized ) end end + + describe '#inspect' do + subject { described_class.new(settings, options).inspect } + + let(:options) do + { + method: :path, + path: '/path', + app: {}, + route_options: { anchor: false }, + forward_match: true, + for: Class.new + } + end + let(:settings) { Grape::Util::InheritableSetting.new } + + it 'does not raise an error' do + expect { subject }.not_to raise_error + end + end end diff --git a/spec/grape/exceptions/base_spec.rb b/spec/grape/exceptions/base_spec.rb index db970a74d..2378fdf6a 100644 --- a/spec/grape/exceptions/base_spec.rb +++ b/spec/grape/exceptions/base_spec.rb @@ -1,8 +1,22 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::Base do + describe '#to_s' do + subject { described_class.new(message: message).to_s } + + let(:message) { 'a_message' } + + it { is_expected.to eq(message) } + end + + describe '#message' do + subject { described_class.new(message: message).message } + + let(:message) { 'a_message' } + + it { is_expected.to eq(message) } + end + describe '#compose_message' do subject { described_class.new.send(:compose_message, key, **attributes) } diff --git a/spec/grape/exceptions/body_parse_errors_spec.rb b/spec/grape/exceptions/body_parse_errors_spec.rb index 990758a5f..06866b7b3 100644 --- a/spec/grape/exceptions/body_parse_errors_spec.rb +++ b/spec/grape/exceptions/body_parse_errors_spec.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::ValidationErrors do context 'api with rescue_from :all handler' do subject { Class.new(Grape::API) } + before do subject.rescue_from :all do |_e| - rack_response 'message was processed', 400 + error! 'message was processed', 400 end subject.params do requires :beer @@ -56,9 +55,10 @@ def app context 'api with rescue_from :grape_exceptions handler' do subject { Class.new(Grape::API) } + before do subject.rescue_from :all do |_e| - rack_response 'message was processed', 400 + error! 'message was processed', 400 end subject.rescue_from :grape_exceptions @@ -91,8 +91,49 @@ def app end end + context 'api with rescue_from :grape_exceptions handler with block' do + subject { Class.new(Grape::API) } + + before do + subject.rescue_from :grape_exceptions do |e| + error! "Custom Error Contents, Original Message: #{e.message}", 400 + end + + subject.params do + requires :beer + end + + subject.post '/beer' do + 'beer received' + end + end + + def app + subject + end + + context 'with content_type json' do + it 'returns body parsing error message' do + post '/beer', 'test', 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq 400 + expect(last_response.body).to include 'message body does not match declared format' + expect(last_response.body).to include 'Custom Error Contents, Original Message' + end + end + + context 'with content_type xml' do + it 'returns body parsing error message' do + post '/beer', 'test', 'CONTENT_TYPE' => 'application/xml' + expect(last_response.status).to eq 400 + expect(last_response.body).to include 'message body does not match declared format' + expect(last_response.body).to include 'Custom Error Contents, Original Message' + end + end + end + context 'api without a rescue handler' do subject { Class.new(Grape::API) } + before do subject.params do requires :beer diff --git a/spec/grape/exceptions/invalid_accept_header_spec.rb b/spec/grape/exceptions/invalid_accept_header_spec.rb index 3a93ce3e1..29884f0dd 100644 --- a/spec/grape/exceptions/invalid_accept_header_spec.rb +++ b/spec/grape/exceptions/invalid_accept_header_spec.rb @@ -1,33 +1,37 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::InvalidAcceptHeader do shared_examples_for 'a valid request' do it 'does return with status 200' do expect(last_response.status).to eq 200 end + it 'does return the expected result' do expect(last_response.body).to eq('beer received') end end + shared_examples_for 'a cascaded request' do it 'does not find a matching route' do expect(last_response.status).to eq 404 end end + shared_examples_for 'a not-cascaded request' do it 'does not include the X-Cascade=pass header' do - expect(last_response.headers['X-Cascade']).to be_nil + expect(last_response.headers).not_to have_key('X-Cascade') end + it 'does not accept the request' do expect(last_response.status).to eq 406 end end + shared_examples_for 'a rescued request' do it 'does not include the X-Cascade=pass header' do expect(last_response.headers['X-Cascade']).to be_nil end + it 'does show rescue handler processing' do expect(last_response.status).to eq 400 expect(last_response.body).to eq('message was processed') @@ -36,10 +40,11 @@ context 'API with cascade=false and rescue_from :all handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: false subject.rescue_from :all do |e| - rack_response 'message was processed', 400, e[:headers] + error! 'message was processed', 400, e[:headers] end subject.get '/beer' do 'beer received' @@ -52,7 +57,8 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do @@ -61,13 +67,15 @@ def app get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a rescued request' + + it_behaves_like 'a rescued request' end end end context 'API with cascade=false and without a rescue handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: false subject.get '/beer' do @@ -81,27 +89,32 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do context 'an invalid version in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77' } - it_should_behave_like 'a not-cascaded request' + + it_behaves_like 'a not-cascaded request' end + context 'an invalid vendor in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99' } - it_should_behave_like 'a not-cascaded request' + + it_behaves_like 'a not-cascaded request' end end end context 'API with cascade=false and with rescue_from :all handler and http_codes' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: false subject.rescue_from :all do |e| - rack_response 'message was processed', 400, e[:headers] + error! 'message was processed', 400, e[:headers] end subject.desc 'Get beer' do failure [[400, 'Bad Request'], [401, 'Unauthorized'], [403, 'Forbidden'], @@ -119,7 +132,8 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do @@ -128,13 +142,15 @@ def app get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a rescued request' + + it_behaves_like 'a rescued request' end end end context 'API with cascade=false, http_codes but without a rescue handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: false subject.desc 'Get beer' do @@ -153,27 +169,32 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do context 'an invalid version in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77' } - it_should_behave_like 'a not-cascaded request' + + it_behaves_like 'a not-cascaded request' end + context 'an invalid vendor in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99' } - it_should_behave_like 'a not-cascaded request' + + it_behaves_like 'a not-cascaded request' end end end context 'API with cascade=true and rescue_from :all handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: true subject.rescue_from :all do |e| - rack_response 'message was processed', 400, e[:headers] + error! 'message was processed', 400, e[:headers] end subject.get '/beer' do 'beer received' @@ -186,7 +207,8 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do @@ -195,20 +217,24 @@ def app get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end + context 'an invalid vendor in the request' do before do get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end end end context 'API with cascade=true and without a rescue handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: true subject.get '/beer' do @@ -222,27 +248,32 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do context 'an invalid version in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77' } - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end + context 'an invalid vendor in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99' } - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end end end context 'API with cascade=true and with rescue_from :all handler and http_codes' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: true subject.rescue_from :all do |e| - rack_response 'message was processed', 400, e[:headers] + error! 'message was processed', 400, e[:headers] end subject.desc 'Get beer' do failure [[400, 'Bad Request'], [401, 'Unauthorized'], [403, 'Forbidden'], @@ -260,7 +291,8 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do @@ -269,20 +301,24 @@ def app get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end + context 'an invalid vendor in the request' do before do get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99', 'CONTENT_TYPE' => 'application/json' end - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end end end context 'API with cascade=true, http_codes but without a rescue handler' do subject { Class.new(Grape::API) } + before do subject.version 'v99', using: :header, vendor: 'vendorname', format: :json, cascade: true subject.desc 'Get beer' do @@ -301,17 +337,21 @@ def app context 'that received a request with correct vendor and version' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v99' } - it_should_behave_like 'a valid request' + + it_behaves_like 'a valid request' end context 'that receives' do context 'an invalid version in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.vendorname-v77' } - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end + context 'an invalid vendor in the request' do before { get '/beer', {}, 'HTTP_ACCEPT' => 'application/vnd.invalidvendor-v99' } - it_should_behave_like 'a cascaded request' + + it_behaves_like 'a cascaded request' end end end diff --git a/spec/grape/exceptions/invalid_formatter_spec.rb b/spec/grape/exceptions/invalid_formatter_spec.rb index 3c60e3d13..03f019747 100644 --- a/spec/grape/exceptions/invalid_formatter_spec.rb +++ b/spec/grape/exceptions/invalid_formatter_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::InvalidFormatter do describe '#message' do let(:error) do diff --git a/spec/grape/exceptions/invalid_response_spec.rb b/spec/grape/exceptions/invalid_response_spec.rb index 8a7c6879b..1e4d579a8 100644 --- a/spec/grape/exceptions/invalid_response_spec.rb +++ b/spec/grape/exceptions/invalid_response_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::InvalidResponse do describe '#message' do let(:error) { described_class.new } diff --git a/spec/grape/exceptions/invalid_versioner_option_spec.rb b/spec/grape/exceptions/invalid_versioner_option_spec.rb index a17079349..d6955bd8a 100644 --- a/spec/grape/exceptions/invalid_versioner_option_spec.rb +++ b/spec/grape/exceptions/invalid_versioner_option_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::InvalidVersionerOption do describe '#message' do let(:error) do @@ -10,7 +8,7 @@ it 'contains the problem in the message' do expect(error.message).to include( - 'Unknown :using for versioner: headers' + 'unknown :using for versioner: headers' ) end end diff --git a/spec/grape/exceptions/missing_group_type_spec.rb b/spec/grape/exceptions/missing_group_type_spec.rb new file mode 100644 index 000000000..7a6ca5269 --- /dev/null +++ b/spec/grape/exceptions/missing_group_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'shared/deprecated_class_examples' + +RSpec.describe Grape::Exceptions::MissingGroupType do + describe '#message' do + subject { described_class.new.message } + + it { is_expected.to include 'group type is required' } + end + + describe 'Grape::Exceptions::MissingGroupTypeError' do + let(:deprecated_class) { Grape::Exceptions::MissingGroupTypeError } + + it_behaves_like 'deprecated class' + end +end diff --git a/spec/grape/exceptions/missing_mime_type_spec.rb b/spec/grape/exceptions/missing_mime_type_spec.rb index b5545cbcc..77441a4ef 100644 --- a/spec/grape/exceptions/missing_mime_type_spec.rb +++ b/spec/grape/exceptions/missing_mime_type_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::MissingMimeType do describe '#message' do let(:error) do diff --git a/spec/grape/exceptions/missing_option_spec.rb b/spec/grape/exceptions/missing_option_spec.rb index 1ae125134..3d8f03e58 100644 --- a/spec/grape/exceptions/missing_option_spec.rb +++ b/spec/grape/exceptions/missing_option_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::MissingOption do describe '#message' do let(:error) do @@ -10,7 +8,7 @@ it 'contains the problem in the message' do expect(error.message).to include( - 'You must specify :path options.' + 'you must specify :path options' ) end end diff --git a/spec/grape/exceptions/unknown_options_spec.rb b/spec/grape/exceptions/unknown_options_spec.rb index 743553719..725d88cba 100644 --- a/spec/grape/exceptions/unknown_options_spec.rb +++ b/spec/grape/exceptions/unknown_options_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::UnknownOptions do describe '#message' do let(:error) do diff --git a/spec/grape/exceptions/unknown_validator_spec.rb b/spec/grape/exceptions/unknown_validator_spec.rb index 2ef256f37..e36daa4d9 100644 --- a/spec/grape/exceptions/unknown_validator_spec.rb +++ b/spec/grape/exceptions/unknown_validator_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::UnknownValidator do describe '#message' do let(:error) do diff --git a/spec/grape/exceptions/unsupported_group_type_spec.rb b/spec/grape/exceptions/unsupported_group_type_spec.rb new file mode 100644 index 000000000..b6282ab81 --- /dev/null +++ b/spec/grape/exceptions/unsupported_group_type_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'shared/deprecated_class_examples' + +RSpec.describe Grape::Exceptions::UnsupportedGroupType do + subject { described_class.new } + + describe '#message' do + subject { described_class.new.message } + + it { is_expected.to include 'group type must be Array, Hash, JSON or Array[JSON]' } + end + + describe 'Grape::Exceptions::UnsupportedGroupTypeError' do + let(:deprecated_class) { Grape::Exceptions::UnsupportedGroupTypeError } + + it_behaves_like 'deprecated class' + end +end diff --git a/spec/grape/exceptions/validation_errors_spec.rb b/spec/grape/exceptions/validation_errors_spec.rb index cf7e1e540..4bba43d65 100644 --- a/spec/grape/exceptions/validation_errors_spec.rb +++ b/spec/grape/exceptions/validation_errors_spec.rb @@ -1,34 +1,32 @@ # frozen_string_literal: true -require 'spec_helper' -require 'ostruct' - describe Grape::Exceptions::ValidationErrors do let(:validation_message) { 'FooBar is invalid' } - let(:validation_error) { OpenStruct.new(params: [validation_message]) } + let(:validation_error) { instance_double Grape::Exceptions::Validation, params: [validation_message], message: '' } context 'initialize' do + subject do + described_class.new(errors: [validation_error], headers: headers) + end + let(:headers) do { 'A-Header-Key' => 'A-Header-Value' } end - subject do - described_class.new(errors: [validation_error], headers: headers) - end - - it 'should assign headers through base class' do + it 'assigns headers through base class' do expect(subject.headers).to eq(headers) end end context 'message' do context 'is not repeated' do + subject(:message) { error.message.split(',').map(&:strip) } + let(:error) do described_class.new(errors: [validation_error, validation_error]) end - subject(:message) { error.message.split(',').map(&:strip) } it { expect(message).to include validation_message } it { expect(message.size).to eq 1 } @@ -37,9 +35,10 @@ describe '#full_messages' do context 'with errors' do + subject { described_class.new(errors: [validation_error_1, validation_error_2]).full_messages } + let(:validation_error_1) { Grape::Exceptions::Validation.new(params: ['id'], message: :presence) } let(:validation_error_2) { Grape::Exceptions::Validation.new(params: ['name'], message: :presence) } - subject { described_class.new(errors: [validation_error_1, validation_error_2]).full_messages } it 'returns an array with each errors full message' do expect(subject).to contain_exactly('id is missing', 'name is missing') @@ -47,9 +46,10 @@ end context 'when attributes is an array of symbols' do - let(:validation_error) { Grape::Exceptions::Validation.new(params: [:admin_field], message: 'Can not set admin-only field') } subject { described_class.new(errors: [validation_error]).full_messages } + let(:validation_error) { Grape::Exceptions::Validation.new(params: [:admin_field], message: 'Can not set admin-only field') } + it 'returns an array with an error full message' do expect(subject.first).to eq('admin_field Can not set admin-only field') end @@ -65,7 +65,7 @@ def app it 'can return structured json with separate fields' do subject.format :json - subject.rescue_from Grape::Exceptions::ValidationErrors do |e| + subject.rescue_from described_class do |e| error!(e, 400) end subject.params do @@ -78,7 +78,7 @@ def app 'exactly_one_of works!' end get '/exactly_one_of', beer: 'string', wine: 'anotherstring' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(JSON.parse(last_response.body)).to eq( [ 'params' => %w[beer wine], diff --git a/spec/grape/exceptions/validation_spec.rb b/spec/grape/exceptions/validation_spec.rb index 5486cbf64..767941122 100644 --- a/spec/grape/exceptions/validation_spec.rb +++ b/spec/grape/exceptions/validation_spec.rb @@ -1,19 +1,19 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Exceptions::Validation do it 'fails when params are missing' do - expect { Grape::Exceptions::Validation.new(message: 'presence') }.to raise_error(ArgumentError, /missing keyword:.+?params/) + expect { described_class.new(message: 'presence') }.to raise_error(ArgumentError, /missing keyword:.+?params/) end + context 'when message is a symbol' do it 'stores message_key' do - expect(Grape::Exceptions::Validation.new(params: ['id'], message: :presence).message_key).to eq(:presence) + expect(described_class.new(params: ['id'], message: :presence).message_key).to eq(:presence) end end + context 'when message is a String' do it 'does not store the message_key' do - expect(Grape::Exceptions::Validation.new(params: ['id'], message: 'presence').message_key).to eq(nil) + expect(described_class.new(params: ['id'], message: 'presence').message_key).to be_nil end end end diff --git a/spec/grape/extensions/param_builders/hash_spec.rb b/spec/grape/extensions/param_builders/hash_spec.rb index 2b68397d0..d4bc7b784 100644 --- a/spec/grape/extensions/param_builders/hash_spec.rb +++ b/spec/grape/extensions/param_builders/hash_spec.rb @@ -1,85 +1,38 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Extensions::Hash::ParamBuilder do - subject { Class.new(Grape::API) } - - def app - subject - end - - describe 'in an endpoint' do - context '#params' do - before do - subject.params do - build_with Grape::Extensions::Hash::ParamBuilder - end - - subject.get do - params.class + describe 'deprecation' do + context 'when included' do + subject do + Class.new(Grape::API) do + include Grape::Extensions::Hash::ParamBuilder end end - it 'should be of type Hash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('Hash') - end - end - end - - describe 'in an api' do - before do - subject.send(:include, Grape::Extensions::Hash::ParamBuilder) - end - - context '#params' do - before do - subject.get do - params.class - end + let(:message) do + 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' end - it 'should be Hash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('Hash') + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with(message).and_raise(ActiveSupport::DeprecationException, :deprecated) + expect { subject }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') end end - it 'symbolizes params keys' do - subject.params do - optional :a, type: Hash do - optional :b, type: Hash do - optional :c, type: String + context 'when using class name' do + let(:app) do + Class.new(Grape::API) do + params do + build_with Grape::Extensions::Hash::ParamBuilder end - optional :d, type: Array + get end end - subject.get '/foo' do - [params[:a][:b][:c], params[:a][:d]] - end - - get '/foo', 'a' => { b: { c: 'bar' }, 'd' => ['foo'] } - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", ["foo"]]') - end - - it 'symbolizes the params' do - subject.params do - build_with Grape::Extensions::Hash::ParamBuilder - requires :a, type: String + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with("#{described_class} has been deprecated. Use short name :hash instead.").and_raise(ActiveSupport::DeprecationException, :deprecated) + expect { get '/' }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') end - - subject.get '/foo' do - [params[:a], params['a']] - end - - get '/foo', a: 'bar' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", nil]') end end end diff --git a/spec/grape/extensions/param_builders/hash_with_indifferent_access_spec.rb b/spec/grape/extensions/param_builders/hash_with_indifferent_access_spec.rb index 4e5b8e6b1..86ae1c84a 100644 --- a/spec/grape/extensions/param_builders/hash_with_indifferent_access_spec.rb +++ b/spec/grape/extensions/param_builders/hash_with_indifferent_access_spec.rb @@ -1,107 +1,38 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder do - subject { Class.new(Grape::API) } - - def app - subject - end - - describe 'in an endpoint' do - context '#params' do - before do - subject.params do - build_with Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder + describe 'deprecation' do + context 'when included' do + subject do + Class.new(Grape::API) do + include Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder end + end - subject.get do - params.class - end + let(:message) do + 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' end - it 'should be of type Hash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('ActiveSupport::HashWithIndifferentAccess') + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with(message).and_raise(ActiveSupport::DeprecationException, :deprecated) + expect { subject }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') end end end - describe 'in an api' do - before do - subject.send(:include, Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder) - end - - context '#params' do - before do - subject.get do - params.class - end - end - - it 'is a Hash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('ActiveSupport::HashWithIndifferentAccess') - end - - it 'parses sub hash params' do - subject.params do - build_with Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder - - optional :a, type: Hash do - optional :b, type: Hash do - optional :c, type: String - end - optional :d, type: Array - end - end - - subject.get '/foo' do - [params[:a]['b'][:c], params['a'][:d]] - end - - get '/foo', a: { b: { c: 'bar' }, d: ['foo'] } - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", ["foo"]]') - end - - it 'params are indifferent to symbol or string keys' do - subject.params do + context 'when using class name' do + let(:app) do + Class.new(Grape::API) do + params do build_with Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder - optional :a, type: Hash do - optional :b, type: Hash do - optional :c, type: String - end - optional :d, type: Array - end - end - - subject.get '/foo' do - [params[:a]['b'][:c], params['a'][:d]] end - - get '/foo', 'a' => { b: { c: 'bar' }, 'd' => ['foo'] } - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", ["foo"]]') + get end + end - it 'responds to string keys' do - subject.params do - build_with Grape::Extensions::ActiveSupport::HashWithIndifferentAccess::ParamBuilder - requires :a, type: String - end - - subject.get '/foo' do - [params[:a], params['a']] - end - - get '/foo', a: 'bar' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", "bar"]') - end + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with("#{described_class} has been deprecated. Use short name :hash_with_indifferent_access instead.").and_raise(ActiveSupport::DeprecationException, :deprecated) + expect { get '/' }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') end end end diff --git a/spec/grape/extensions/param_builders/hashie/mash_spec.rb b/spec/grape/extensions/param_builders/hashie/mash_spec.rb deleted file mode 100644 index b533f5657..000000000 --- a/spec/grape/extensions/param_builders/hashie/mash_spec.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Grape::Extensions::Hashie::Mash::ParamBuilder do - subject { Class.new(Grape::API) } - - def app - subject - end - - describe 'in an endpoint' do - context '#params' do - before do - subject.params do - build_with Grape::Extensions::Hashie::Mash::ParamBuilder - end - - subject.get do - params.class - end - end - - it 'should be of type Hashie::Mash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('Hashie::Mash') - end - end - end - - describe 'in an api' do - before do - subject.send(:include, Grape::Extensions::Hashie::Mash::ParamBuilder) - end - - context '#params' do - before do - subject.get do - params.class - end - end - - it 'should be Hashie::Mash' do - get '/' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('Hashie::Mash') - end - end - - context 'in a nested namespace api' do - before do - subject.namespace :foo do - get do - params.class - end - end - end - - it 'should be Hashie::Mash' do - get '/foo' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('Hashie::Mash') - end - end - - it 'is indifferent to key or symbol access' do - subject.params do - build_with Grape::Extensions::Hashie::Mash::ParamBuilder - requires :a, type: String - end - subject.get '/foo' do - [params[:a], params['a']] - end - - get '/foo', a: 'bar' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('["bar", "bar"]') - end - end -end diff --git a/spec/grape/integration/global_namespace_function_spec.rb b/spec/grape/integration/global_namespace_function_spec.rb index 32a55f7de..0c429fb74 100644 --- a/spec/grape/integration/global_namespace_function_spec.rb +++ b/spec/grape/integration/global_namespace_function_spec.rb @@ -2,8 +2,6 @@ # see https://github.com/ruby-grape/grape/issues/1348 -require 'spec_helper' - def namespace raise end diff --git a/spec/grape/integration/rack_sendfile_spec.rb b/spec/grape/integration/rack_sendfile_spec.rb index ab7cb1ba0..2864ca101 100644 --- a/spec/grape/integration/rack_sendfile_spec.rb +++ b/spec/grape/integration/rack_sendfile_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Rack::Sendfile do subject do content_object = file_object @@ -18,7 +16,7 @@ end options = { - method: 'GET', + method: Rack::GET, 'HTTP_X_SENDFILE_TYPE' => 'X-Accel-Redirect', 'HTTP_X_ACCEL_MAPPING' => '/accel/mapping/=/replaced/' } @@ -44,7 +42,7 @@ it 'not contains Sendfile headers' do headers = subject[1] - expect(headers).to_not include('X-Accel-Redirect') + expect(headers).not_to include('X-Accel-Redirect') end end end diff --git a/spec/grape/integration/rack_spec.rb b/spec/grape/integration/rack_spec.rb index e1c46762f..b68b24a60 100644 --- a/spec/grape/integration/rack_spec.rb +++ b/spec/grape/integration/rack_spec.rb @@ -1,42 +1,64 @@ # frozen_string_literal: true -require 'spec_helper' - describe Rack do - it 'correctly populates params from a Tempfile' do - input = Tempfile.new 'rubbish' - begin - app = Class.new(Grape::API) do + describe 'from a Tempfile' do + subject { last_response.body } + + let(:app) do + Class.new(Grape::API) do format :json + + params do + requires :file, type: File + end + post do - { params_keys: params.keys } + params[:file].then do |file| + { + filename: file[:filename], + type: file[:type], + content: file[:tempfile].read + } + end end end - input.write({ test: '123' * 10_000 }.to_json) - input.rewind - options = { - input: input, - method: 'POST', - 'CONTENT_TYPE' => 'application/json' - } - env = Rack::MockRequest.env_for('/', options) - - expect(JSON.parse(read_chunks(app.call(env)[2]).join)['params_keys']).to match_array('test') + end + + let(:response_body) do + { + filename: File.basename(tempfile.path), + type: 'text/plain', + content: 'rubbish' + }.to_json + end + + let(:tempfile) do + Tempfile.new.tap do |t| + t.write('rubbish') + t.rewind + end + end + + before do + post '/', file: Rack::Test::UploadedFile.new(tempfile.path, 'text/plain') + end + + it 'correctly populates params from a Tempfile' do + expect(subject).to eq(response_body) ensure - input.close - input.unlink + tempfile.close! end end context 'when the app is mounted' do - def app - @main_app ||= Class.new(Grape::API) do + let(:ping_mount) do + Class.new(Grape::API) do get 'ping' end end - let!(:base) do - app_to_mount = app + let(:app) do + app_to_mount = ping_mount Class.new(Grape::API) do namespace 'namespace' do mount app_to_mount @@ -46,7 +68,7 @@ def app it 'finds the app on the namespace' do get '/namespace/ping' - expect(last_response.status).to eq 200 + expect(last_response).to be_successful end end end diff --git a/spec/grape/loading_spec.rb b/spec/grape/loading_spec.rb index e28891cce..f091fef85 100644 --- a/spec/grape/loading_spec.rb +++ b/spec/grape/loading_spec.rb @@ -1,8 +1,15 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::API do + subject do + context = self + + Class.new(Grape::API) do + format :json + mount context.combined_api => '/' + end + end + let(:jobs_api) do Class.new(Grape::API) do namespace :one do @@ -10,6 +17,7 @@ namespace :three do get :one do end + get :two do end end @@ -19,18 +27,11 @@ end let(:combined_api) do - JobsApi = jobs_api - Class.new(Grape::API) do - version :v1, using: :accept_version_header, cascade: true - mount JobsApi - end - end + context = self - subject do - CombinedApi = combined_api Class.new(Grape::API) do - format :json - mount CombinedApi => '/' + version :v1, using: :accept_version_header, cascade: true + mount context.jobs_api end end diff --git a/spec/grape/middleware/auth/base_spec.rb b/spec/grape/middleware/auth/base_spec.rb index d18433698..7ff7d0ad4 100644 --- a/spec/grape/middleware/auth/base_spec.rb +++ b/spec/grape/middleware/auth/base_spec.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' -require 'base64' - describe Grape::Middleware::Auth::Base do subject do Class.new(Grape::API) do @@ -15,18 +12,16 @@ end end - def app - subject - end + let(:app) { subject } it 'authenticates if given valid creds' do get '/authorized', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('DONE') end it 'throws a 401 is wrong auth is given' do get '/authorized', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'wrong') - expect(last_response.status).to eq(401) + expect(last_response).to be_unauthorized end end diff --git a/spec/grape/middleware/auth/dsl_spec.rb b/spec/grape/middleware/auth/dsl_spec.rb index 338d17c7c..bd1961f14 100644 --- a/spec/grape/middleware/auth/dsl_spec.rb +++ b/spec/grape/middleware/auth/dsl_spec.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Auth::DSL do subject { Class.new(Grape::API) } - let(:block) { ->() {} } + let(:block) { -> {} } let(:settings) do { opaque: 'secret', @@ -16,7 +14,7 @@ end describe '.auth' do - it 'stets auth parameters' do + it 'sets auth parameters' do expect(subject.base_instance).to receive(:use).with(Grape::Middleware::Auth::Base, settings) subject.auth :http_digest, realm: settings[:realm], opaque: settings[:opaque], &settings[:proc] @@ -38,16 +36,25 @@ end describe '.http_basic' do - it 'stets auth parameters' do + it 'sets auth parameters' do subject.http_basic realm: 'my_realm', &settings[:proc] expect(subject.auth).to eq(realm: 'my_realm', type: :http_basic, proc: block) end end describe '.http_digest' do - it 'stets auth parameters' do - subject.http_digest realm: 'my_realm', opaque: 'my_opaque', &settings[:proc] - expect(subject.auth).to eq(realm: 'my_realm', type: :http_digest, proc: block, opaque: 'my_opaque') + context 'when realm is a hash' do + it 'sets auth parameters' do + subject.http_digest realm: { realm: 'my_realm', opaque: 'my_opaque' }, &settings[:proc] + expect(subject.auth).to eq(realm: { realm: 'my_realm', opaque: 'my_opaque' }, type: :http_digest, proc: block) + end + end + + context 'when realm is not hash' do + it 'sets auth parameters' do + subject.http_digest realm: 'my_realm', opaque: 'my_opaque', &settings[:proc] + expect(subject.auth).to eq(realm: 'my_realm', type: :http_digest, proc: block, opaque: 'my_opaque') + end end end end diff --git a/spec/grape/middleware/auth/strategies_spec.rb b/spec/grape/middleware/auth/strategies_spec.rb index 954c99631..c3efde0f0 100644 --- a/spec/grape/middleware/auth/strategies_spec.rb +++ b/spec/grape/middleware/auth/strategies_spec.rb @@ -1,82 +1,30 @@ # frozen_string_literal: true -require 'spec_helper' - -require 'base64' - describe Grape::Middleware::Auth::Strategies do - context 'Basic Auth' do - def app + describe 'Basic Auth' do + let(:app) do proc = ->(u, p) { u && p && u == p } - Rack::Builder.new do |b| - b.use Grape::Middleware::Error - b.use(Grape::Middleware::Auth::Base, type: :http_basic, proc: proc) - b.run ->(_env) { [200, {}, ['Hello there.']] } + Rack::Builder.app do + use Grape::Middleware::Error + use(Grape::Middleware::Auth::Base, type: :http_basic, proc: proc) + run ->(_env) { [200, {}, ['Hello there.']] } end end it 'throws a 401 if no auth is given' do - @proc = -> { false } get '/whatever' - expect(last_response.status).to eq(401) + expect(last_response).to be_unauthorized end it 'authenticates if given valid creds' do get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'admin') - expect(last_response.status).to eq(200) + expect(last_response).to be_successful + expect(last_response.body).to eq('Hello there.') end it 'throws a 401 is wrong auth is given' do get '/whatever', {}, 'HTTP_AUTHORIZATION' => encode_basic_auth('admin', 'wrong') - expect(last_response.status).to eq(401) - end - end - - context 'Digest MD5 Auth' do - RSpec::Matchers.define :be_challenge do - match do |actual_response| - actual_response.status == 401 && - actual_response['WWW-Authenticate'].start_with?('Digest ') && - actual_response.body.empty? - end - end - - module StrategiesSpec - class Test < Grape::API - http_digest(realm: 'Test Api', opaque: 'secret') do |username| - { 'foo' => 'bar' }[username] - end - - get '/test' do - [{ hey: 'you' }, { there: 'bar' }, { foo: 'baz' }] - end - end - end - - def app - StrategiesSpec::Test - end - - it 'is a digest authentication challenge' do - get '/test' - expect(last_response).to be_challenge - end - - it 'throws a 401 if no auth is given' do - get '/test' - expect(last_response.status).to eq(401) - end - - it 'authenticates if given valid creds' do - digest_authorize 'foo', 'bar' - get '/test' - expect(last_response.status).to eq(200) - end - - it 'throws a 401 if given invalid creds' do - digest_authorize 'bar', 'foo' - get '/test' - expect(last_response.status).to eq(401) + expect(last_response).to be_unauthorized end end end diff --git a/spec/grape/middleware/base_spec.rb b/spec/grape/middleware/base_spec.rb index 02a745e11..7bfedcfe6 100644 --- a/spec/grape/middleware/base_spec.rb +++ b/spec/grape/middleware/base_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Base do - subject { Grape::Middleware::Base.new(blank_app) } + subject { described_class.new(blank_app) } + let(:blank_app) { ->(_) { [200, {}, 'Hi there.'] } } before do @@ -20,6 +19,8 @@ end context 'callbacks' do + after { subject.call!({}) } + it 'calls #before' do expect(subject).to receive(:before) end @@ -27,8 +28,6 @@ it 'calls #after' do expect(subject).to receive(:after) end - - after { subject.call!({}) } end context 'callbacks on error' do @@ -58,7 +57,7 @@ context 'with patched warnings' do before do @warnings = warnings = [] - allow_any_instance_of(Grape::Middleware::Base).to receive(:warn) { |m| warnings << m } + allow(subject).to receive(:warn) { |m| warnings << m } allow(subject).to receive(:after).and_raise(StandardError) end @@ -71,53 +70,63 @@ it 'is able to access the response' do subject.call({}) - expect(subject.response).to be_kind_of(Rack::Response) + expect(subject.response).to be_a(Rack::Response) end describe '#response' do - subject { Grape::Middleware::Base.new(response) } + subject do + described_class.new(response) + end - context Array do + before { subject.call({}) } + + context 'when Array' do + let(:rack_response) { Rack::Response.new('test', 204, abc: 1) } let(:response) { ->(_) { [204, { abc: 1 }, 'test'] } } it 'status' do - subject.call({}) expect(subject.response.status).to eq(204) end it 'body' do - subject.call({}) expect(subject.response.body).to eq(['test']) end it 'header' do - subject.call({}) - expect(subject.response.header).to have_key(:abc) + expect(subject.response.headers).to have_key(:abc) + end + + it 'returns the memoized Rack::Response instance' do + allow(Rack::Response).to receive(:new).and_return(rack_response) + expect(subject.response).to eq(rack_response) end end - context Rack::Response do - let(:response) { ->(_) { Rack::Response.new('test', 204, abc: 1) } } + context 'when Rack::Response' do + let(:rack_response) { Rack::Response.new('test', 204, abc: 1) } + let(:response) { ->(_) { rack_response } } it 'status' do - subject.call({}) expect(subject.response.status).to eq(204) end it 'body' do - subject.call({}) expect(subject.response.body).to eq(['test']) end it 'header' do - subject.call({}) - expect(subject.response.header).to have_key(:abc) + expect(subject.response.headers).to have_key(:abc) + end + + it 'returns the memoized Rack::Response instance' do + expect(subject.response).to eq(rack_response) end end end describe '#context' do - subject { Grape::Middleware::Base.new(blank_app) } + subject { described_class.new(blank_app) } + it 'allows access to response context' do subject.call(Grape::Env::API_ENDPOINT => { header: 'some header' }) expect(subject.context).to eq(header: 'some header') @@ -126,12 +135,12 @@ context 'options' do it 'persists options passed at initialization' do - expect(Grape::Middleware::Base.new(blank_app, abc: true).options[:abc]).to be true + expect(described_class.new(blank_app, abc: true).options[:abc]).to be true end context 'defaults' do - module BaseSpec - class ExampleWare < Grape::Middleware::Base + let(:example_ware) do + Class.new(Grape::Middleware::Base) do def default_options { monkey: true } end @@ -139,18 +148,18 @@ def default_options end it 'persists the default options' do - expect(BaseSpec::ExampleWare.new(blank_app).options[:monkey]).to be true + expect(example_ware.new(blank_app).options[:monkey]).to be true end it 'overrides default options when provided' do - expect(BaseSpec::ExampleWare.new(blank_app, monkey: false).options[:monkey]).to be false + expect(example_ware.new(blank_app, monkey: false).options[:monkey]).to be false end end end context 'header' do - module HeaderSpec - class ExampleWare < Grape::Middleware::Base + let(:example_ware) do + Class.new(Grape::Middleware::Base) do def before header 'X-Test-Before', 'Hi' end @@ -162,9 +171,11 @@ def after end end - def app + let(:app) do + context = self + Rack::Builder.app do - use HeaderSpec::ExampleWare + use context.example_ware run ->(_) { [200, {}, ['Yeah']] } end end @@ -177,8 +188,8 @@ def app end context 'header overwrite' do - module HeaderOverwritingSpec - class ExampleWare < Grape::Middleware::Base + let(:example_ware) do + Class.new(Grape::Middleware::Base) do def before header 'X-Test-Overwriting', 'Hi' end @@ -188,8 +199,9 @@ def after nil end end - - class API < Grape::API + end + let(:api) do + Class.new(Grape::API) do get('/') do header 'X-Test-Overwriting', 'Yeah' 'Hello' @@ -197,10 +209,12 @@ class API < Grape::API end end - def app + let(:app) do + context = self + Rack::Builder.app do - use HeaderOverwritingSpec::ExampleWare - run HeaderOverwritingSpec::API.new + use context.example_ware + run context.api.new end end @@ -209,4 +223,36 @@ def app expect(last_response.headers['X-Test-Overwriting']).to eq('Bye') end end + + describe 'query_params' do + let(:dummy_middleware) do + Class.new(Grape::Middleware::Base) do + def before + query_params + end + end + end + + let(:app) do + context = self + Rack::Builder.app do + use context.dummy_middleware + run ->(_) { [200, {}, ['Yeah']] } + end + end + + context 'when query params are conflicting' do + it 'raises an ConflictingTypes error' do + expect { get '/?x[y]=1&x[y]z=2' }.to raise_error(Grape::Exceptions::ConflictingTypes) + end + end + + context 'when query params is over the specified limit' do + let(:query_params) { "foo#{'[a]' * Rack::Utils.param_depth_limit}=bar" } + + it 'raises an ConflictingTypes error' do + expect { get "/?foo#{'[a]' * Rack::Utils.param_depth_limit}=bar" }.to raise_error(Grape::Exceptions::TooDeepParameters) + end + end + end end diff --git a/spec/grape/middleware/error_spec.rb b/spec/grape/middleware/error_spec.rb index d586b9820..cc8a735d4 100644 --- a/spec/grape/middleware/error_spec.rb +++ b/spec/grape/middleware/error_spec.rb @@ -1,11 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' -require 'grape-entity' - describe Grape::Middleware::Error do - module ErrorSpec - class ErrorEntity < Grape::Entity + let(:error_entity) do + Class.new(Grape::Entity) do expose :code expose :static @@ -13,11 +10,11 @@ def static 'static text' end end - - class ErrApp + end + let(:err_app) do + Class.new do class << self - attr_accessor :error - attr_accessor :format + attr_accessor :error, :format def call(_env) throw :error, error @@ -25,56 +22,56 @@ def call(_env) end end end + let(:options) { { default_message: 'Aww, hamburgers.' } } - def app + let(:app) do opts = options + context = self Rack::Builder.app do use Spec::Support::EndpointFaker - use Grape::Middleware::Error, **opts - run ErrorSpec::ErrApp + use Grape::Middleware::Error, opts # rubocop:disable RSpec/DescribedClass + run context.err_app end end - let(:options) { { default_message: 'Aww, hamburgers.' } } - it 'sets the status code appropriately' do - ErrorSpec::ErrApp.error = { status: 410 } + err_app.error = { status: 410 } + get '/' + expect(last_response.status).to eq(410) + end + + it 'sets the status code based on the rack util status code symbol' do + err_app.error = { status: :gone } get '/' expect(last_response.status).to eq(410) end it 'sets the error message appropriately' do - ErrorSpec::ErrApp.error = { message: 'Awesome stuff.' } + err_app.error = { message: 'Awesome stuff.' } get '/' expect(last_response.body).to eq('Awesome stuff.') end it 'defaults to a 500 status' do - ErrorSpec::ErrApp.error = {} + err_app.error = {} get '/' - expect(last_response.status).to eq(500) + expect(last_response).to be_server_error end it 'has a default message' do - ErrorSpec::ErrApp.error = {} + err_app.error = {} get '/' expect(last_response.body).to eq('Aww, hamburgers.') end context 'with http code' do let(:options) { { default_message: 'Aww, hamburgers.' } } + it 'adds the status code if wanted' do - ErrorSpec::ErrApp.error = { message: { code: 200 } } + err_app.error = { message: { code: 200 } } get '/' expect(last_response.body).to eq({ code: 200 }.to_json) end - - it 'presents an error message' do - ErrorSpec::ErrApp.error = { message: { code: 200, with: ErrorSpec::ErrorEntity } } - get '/' - - expect(last_response.body).to eq({ code: 200, static: 'static text' }.to_json) - end end end diff --git a/spec/grape/middleware/exception_spec.rb b/spec/grape/middleware/exception_spec.rb index 11aa8e5aa..ccd48b7b7 100644 --- a/spec/grape/middleware/exception_spec.rb +++ b/spec/grape/middleware/exception_spec.rb @@ -1,29 +1,38 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Error do - # raises a text exception - module ExceptionSpec - class ExceptionApp + let(:exception_app) do + Class.new do class << self def call(_env) raise 'rain!' end end end + end - # raises a non-StandardError (ScriptError) exception - class OtherExceptionApp + let(:other_exception_app) do + Class.new do class << self def call(_env) raise NotImplementedError, 'snow!' end end end + end + + let(:custom_error_app) do + custom_error = Class.new(Grape::Exceptions::Base) + + Class.new do + define_singleton_method(:call) do |_env| + raise custom_error.new(status: 400, message: 'failed validation') + end + end + end - # raises a hash error - class ErrorHashApp + let(:error_hash_app) do + Class.new do class << self def error!(message, status) throw :error, message: { error: message, detail: 'missing widget' }, status: status @@ -34,9 +43,10 @@ def call(_env) end end end + end - # raises an error! - class AccessDeniedApp + let(:access_denied_app) do + Class.new do class << self def error!(message, status) throw :error, message: message, status: status @@ -47,32 +57,27 @@ def call(_env) end end end + end - # raises a custom error - class CustomError < Grape::Exceptions::Base - end - - class CustomErrorApp - class << self - def call(_env) - raise CustomError.new(status: 400, message: 'failed validation') - end + let(:app) do + opts = options + app = running_app + Rack::Builder.app do + use Rack::Lint + use Spec::Support::EndpointFaker + if opts.any? + use Grape::Middleware::Error, opts + else + use Grape::Middleware::Error end + run app end end - def app - subject - end - context 'with defaults' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error - run ExceptionSpec::ExceptionApp - end - end + let(:running_app) { exception_app } + let(:options) { {} } + it 'does not trap errors by default' do expect { get '/' }.to raise_error(RuntimeError, 'rain!') end @@ -80,17 +85,14 @@ def app context 'with rescue_all' do context 'StandardError exception' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true - run ExceptionSpec::ExceptionApp - end - end + let(:running_app) { exception_app } + let(:options) { { rescue_all: true } } + it 'sets the message appropriately' do get '/' expect(last_response.body).to eq('rain!') end + it 'defaults to a 500 status' do get '/' expect(last_response.status).to eq(500) @@ -98,13 +100,9 @@ def app end context 'Non-StandardError exception' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true - run ExceptionSpec::OtherExceptionApp - end - end + let(:running_app) { other_exception_app } + let(:options) { { rescue_all: true } } + it 'does not trap errors other than StandardError' do expect { get '/' }.to raise_error(NotImplementedError, 'snow!') end @@ -113,13 +111,9 @@ def app context 'Non-StandardError exception with a provided rescue handler' do context 'default error response' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_handlers: { NotImplementedError => nil } - run ExceptionSpec::OtherExceptionApp - end - end + let(:running_app) { other_exception_app } + let(:options) { { rescue_handlers: { NotImplementedError => nil } } } + it 'rescues the exception using the default handler' do get '/' expect(last_response.body).to eq('snow!') @@ -127,13 +121,9 @@ def app end context 'custom error response' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_handlers: { NotImplementedError => -> { Rack::Response.new('rescued', 200, {}) } } - run ExceptionSpec::OtherExceptionApp - end - end + let(:running_app) { other_exception_app } + let(:options) { { rescue_handlers: { NotImplementedError => -> { Rack::Response.new('rescued', 200, {}) } } } } + it 'rescues the exception using the provided handler' do get '/' expect(last_response.body).to eq('rescued') @@ -142,13 +132,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, default_status: 500 - run ExceptionSpec::ExceptionApp - end - end + let(:running_app) { exception_app } + let(:options) { { rescue_all: true, default_status: 500 } } + it 'is possible to specify a different default status code' do get '/' expect(last_response.status).to eq(500) @@ -156,13 +142,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :json - run ExceptionSpec::ExceptionApp - end - end + let(:running_app) { exception_app } + let(:options) { { rescue_all: true, format: :json } } + it 'is possible to return errors in json format' do get '/' expect(last_response.body).to eq('{"error":"rain!"}') @@ -170,13 +152,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :json - run ExceptionSpec::ErrorHashApp - end - end + let(:running_app) { error_hash_app } + let(:options) { { rescue_all: true, format: :json } } + it 'is possible to return hash errors in json format' do get '/' expect(['{"error":"rain!","detail":"missing widget"}', @@ -185,43 +163,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :jsonapi - run ExceptionSpec::ExceptionApp - end - end - it 'is possible to return errors in jsonapi format' do - get '/' - expect(last_response.body).to eq('{"error":"rain!"}') - end - end - - context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :jsonapi - run ExceptionSpec::ErrorHashApp - end - end - - it 'is possible to return hash errors in jsonapi format' do - get '/' - expect(['{"error":"rain!","detail":"missing widget"}', - '{"detail":"missing widget","error":"rain!"}']).to include(last_response.body) - end - end + let(:running_app) { exception_app } + let(:options) { { rescue_all: true, format: :xml } } - context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :xml - run ExceptionSpec::ExceptionApp - end - end it 'is possible to return errors in xml format' do get '/' expect(last_response.body).to eq("\n\n rain!\n\n") @@ -229,13 +173,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: true, format: :xml - run ExceptionSpec::ErrorHashApp - end - end + let(:running_app) { error_hash_app } + let(:options) { { rescue_all: true, format: :xml } } + it 'is possible to return hash errors in xml format' do get '/' expect(["\n\n missing widget\n rain!\n\n", @@ -244,34 +184,30 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, - rescue_all: true, - format: :custom, - error_formatters: { - custom: lambda do |message, _backtrace, _options, _env, _original_exception| - { custom_formatter: message }.inspect - end - } - run ExceptionSpec::ExceptionApp - end + let(:running_app) { exception_app } + let(:options) do + { + rescue_all: true, + format: :custom, + error_formatters: { + custom: lambda do |message, _backtrace, _options, _env, _original_exception| + { custom_formatter: message }.inspect + end + } + } end + it 'is possible to specify a custom formatter' do get '/' - expect(last_response.body).to eq('{:custom_formatter=>"rain!"}') + response = Rack::Utils.escape_html({ custom_formatter: 'rain!' }.inspect) + expect(last_response.body).to eq(response) end end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error - run ExceptionSpec::AccessDeniedApp - end - end + let(:running_app) { access_denied_app } + let(:options) { {} } + it 'does not trap regular error! codes' do get '/' expect(last_response.status).to eq(401) @@ -279,13 +215,9 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, rescue_all: false - run ExceptionSpec::CustomErrorApp - end - end + let(:running_app) { custom_error_app } + let(:options) { { rescue_all: false } } + it 'responds to custom Grape exceptions appropriately' do get '/' expect(last_response.status).to eq(400) @@ -294,16 +226,15 @@ def app end context 'with rescue_options :backtrace and :exception set to true' do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, - rescue_all: true, - format: :json, - rescue_options: { backtrace: true, original_exception: true } - run ExceptionSpec::ExceptionApp - end + let(:running_app) { exception_app } + let(:options) do + { + rescue_all: true, + format: :json, + rescue_options: { backtrace: true, original_exception: true } + } end + it 'is possible to return the backtrace and the original exception in json format' do get '/' expect(last_response.body).to include('error', 'rain!', 'backtrace', 'original_exception', 'RuntimeError') @@ -311,16 +242,15 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, - rescue_all: true, - format: :xml, - rescue_options: { backtrace: true, original_exception: true } - run ExceptionSpec::ExceptionApp - end + let(:running_app) { exception_app } + let(:options) do + { + rescue_all: true, + format: :xml, + rescue_options: { backtrace: true, original_exception: true } + } end + it 'is possible to return the backtrace and the original exception in xml format' do get '/' expect(last_response.body).to include('error', 'rain!', 'backtrace', 'original-exception', 'RuntimeError') @@ -328,16 +258,15 @@ def app end context do - subject do - Rack::Builder.app do - use Spec::Support::EndpointFaker - use Grape::Middleware::Error, - rescue_all: true, - format: :txt, - rescue_options: { backtrace: true, original_exception: true } - run ExceptionSpec::ExceptionApp - end + let(:running_app) { exception_app } + let(:options) do + { + rescue_all: true, + format: :txt, + rescue_options: { backtrace: true, original_exception: true } + } end + it 'is possible to return the backtrace and the original exception in txt format' do get '/' expect(last_response.body).to include('error', 'rain!', 'backtrace', 'original exception', 'RuntimeError') diff --git a/spec/grape/middleware/formatter_spec.rb b/spec/grape/middleware/formatter_spec.rb index 9f6333cd0..807edaad4 100644 --- a/spec/grape/middleware/formatter_spec.rb +++ b/spec/grape/middleware/formatter_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Formatter do - subject { Grape::Middleware::Formatter.new(app) } + subject { described_class.new(app) } + before { allow(subject).to receive(:dup).and_return(subject) } let(:body) { { 'foo' => 'bar' } } @@ -11,53 +10,56 @@ context 'serialization' do let(:body) { { 'abc' => 'def' } } + let(:env) do + { Rack::PATH_INFO => '/somewhere', 'HTTP_ACCEPT' => 'application/json' } + end + it 'looks at the bodies for possibly serializable data' do - _, _, bodies = *subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json') - bodies.each { |b| expect(b).to eq(::Grape::Json.dump(body)) } + r = Rack::MockResponse[*subject.call(env)] + expect(r.body).to eq(Grape::Json.dump(body)) end context 'default format' do let(:body) { ['foo'] } - it 'calls #to_json since default format is json' do - body.instance_eval do - def to_json - '"bar"' - end - end - - subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json').to_a.last.each { |b| expect(b).to eq('"bar"') } + let(:env) do + { Rack::PATH_INFO => '/somewhere', 'HTTP_ACCEPT' => 'application/json' } end - end - context 'jsonapi' do - let(:body) { { 'foos' => [{ 'bar' => 'baz' }] } } - it 'calls #to_json if the content type is jsonapi' do + it 'calls #to_json since default format is json' do body.instance_eval do - def to_json - '{"foos":[{"bar":"baz"}] }' + def to_json(*_args) + '"bar"' end end - - subject.call('PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/vnd.api+json').to_a.last.each { |b| expect(b).to eq('{"foos":[{"bar":"baz"}] }') } + r = Rack::MockResponse[*subject.call(env)] + expect(r.body).to eq('"bar"') end end context 'xml' do let(:body) { +'string' } + let(:env) do + { Rack::PATH_INFO => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json' } + end + it 'calls #to_xml if the content type is xml' do body.instance_eval do def to_xml '' end end - - subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json').to_a.last.each { |b| expect(b).to eq('') } + r = Rack::MockResponse[*subject.call(env)] + expect(r.body).to eq('') end end end context 'error handling' do let(:formatter) { double(:formatter) } + let(:env) do + { Rack::PATH_INFO => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json' } + end + before do allow(Grape::Formatter).to receive(:formatter_for) { formatter } end @@ -66,162 +68,190 @@ def to_xml allow(formatter).to receive(:call) { raise Grape::Exceptions::InvalidFormatter.new(String, 'xml') } expect do - catch(:error) { subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json') } - end.to_not raise_error + catch(:error) { subject.call(env) } + end.not_to raise_error end it 'does not rescue other exceptions' do allow(formatter).to receive(:call) { raise StandardError } expect do - catch(:error) { subject.call('PATH_INFO' => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json') } + catch(:error) { subject.call(Rack::PATH_INFO => '/somewhere.xml', 'HTTP_ACCEPT' => 'application/json') } end.to raise_error(StandardError) end end context 'detection' do + context 'when path contains invalid byte sequence' do + it 'does not raise an exception' do + expect { subject.call(Rack::PATH_INFO => "/info.\x80") }.not_to raise_error + end + end + it 'uses the xml extension if one is provided' do - subject.call('PATH_INFO' => '/info.xml') - expect(subject.env['api.format']).to eq(:xml) + subject.call(Rack::PATH_INFO => '/info.xml') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) end it 'uses the json extension if one is provided' do - subject.call('PATH_INFO' => '/info.json') - expect(subject.env['api.format']).to eq(:json) + subject.call(Rack::PATH_INFO => '/info.json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end it 'uses the format parameter if one is provided' do - subject.call('PATH_INFO' => '/info', 'QUERY_STRING' => 'format=json') - expect(subject.env['api.format']).to eq(:json) - subject.call('PATH_INFO' => '/info', 'QUERY_STRING' => 'format=xml') - expect(subject.env['api.format']).to eq(:xml) + subject.call(Rack::PATH_INFO => '/info', Rack::QUERY_STRING => 'format=json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end it 'uses the default format if none is provided' do - subject.call('PATH_INFO' => '/info') - expect(subject.env['api.format']).to eq(:txt) + subject.call(Rack::PATH_INFO => '/info') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:txt) end it 'uses the requested format if provided in headers' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json') - expect(subject.env['api.format']).to eq(:json) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end it 'uses the file extension format if provided before headers' do - subject.call('PATH_INFO' => '/info.txt', 'HTTP_ACCEPT' => 'application/json') - expect(subject.env['api.format']).to eq(:txt) + subject.call(Rack::PATH_INFO => '/info.txt', 'HTTP_ACCEPT' => 'application/json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:txt) end end context 'accept header detection' do + context 'when header contains invalid byte sequence' do + it 'does not raise an exception' do + expect { subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => "Hello \x80") }.not_to raise_error + end + end + it 'detects from the Accept header' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/xml') - expect(subject.env['api.format']).to eq(:xml) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/xml') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) end it 'uses quality rankings to determine formats' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; q=0.3,application/xml; q=1.0') - expect(subject.env['api.format']).to eq(:xml) - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; q=1.0,application/xml; q=0.3') - expect(subject.env['api.format']).to eq(:json) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json; q=0.3,application/xml; q=1.0') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) + + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json; q=1.0,application/xml; q=0.3') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end it 'handles quality rankings mixed with nothing' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json,application/xml; q=1.0') - expect(subject.env['api.format']).to eq(:xml) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json,application/xml; q=1.0') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) + + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/xml; q=1.0,application/json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) + end + + it 'handles quality rankings that have a default 1.0 value' do + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json,application/xml;q=0.5') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/xml;q=0.5,application/json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end it 'parses headers with other attributes' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/json; abc=2.3; q=1.0,application/xml; q=0.7') - expect(subject.env['api.format']).to eq(:json) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json; abc=2.3; q=1.0,application/xml; q=0.7') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:json) end - it 'parses headers with vendor and api version' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test-v1+xml') - expect(subject.env['api.format']).to eq(:xml) + it 'ensures that a quality of 0 is less preferred than any other content type' do + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/json;q=0.0,application/xml') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/xml,application/json;q=0.0') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:xml) end context 'with custom vendored content types' do - before do - subject.options[:content_types] = {} - subject.options[:content_types][:custom] = 'application/vnd.test+json' + context 'when registered' do + subject { described_class.new(app, content_types: { custom: 'application/vnd.test+json' }) } + + it 'uses the custom type' do + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json') + expect(subject.env[Grape::Env::API_FORMAT]).to eq(:custom) + end end - it 'it uses the custom type' do - subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json') - expect(subject.env['api.format']).to eq(:custom) + context 'when unregistered' do + it 'returns the default content type text/plain' do + r = Rack::MockResponse[*subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json')] + expect(r.headers[Rack::CONTENT_TYPE]).to eq('text/plain') + end end end it 'parses headers with symbols as hash keys' do - subject.call('PATH_INFO' => '/info', 'http_accept' => 'application/xml', system_time: '091293') + subject.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/xml', system_time: '091293') expect(subject.env[:system_time]).to eq('091293') end end context 'content-type' do it 'is set for json' do - _, headers, = subject.call('PATH_INFO' => '/info.json') - expect(headers['Content-type']).to eq('application/json') + _, headers, = subject.call(Rack::PATH_INFO => '/info.json') + expect(headers[Rack::CONTENT_TYPE]).to eq('application/json') end + it 'is set for xml' do - _, headers, = subject.call('PATH_INFO' => '/info.xml') - expect(headers['Content-type']).to eq('application/xml') + _, headers, = subject.call(Rack::PATH_INFO => '/info.xml') + expect(headers[Rack::CONTENT_TYPE]).to eq('application/xml') end + it 'is set for txt' do - _, headers, = subject.call('PATH_INFO' => '/info.txt') - expect(headers['Content-type']).to eq('text/plain') + _, headers, = subject.call(Rack::PATH_INFO => '/info.txt') + expect(headers[Rack::CONTENT_TYPE]).to eq('text/plain') end + it 'is set for custom' do - subject.options[:content_types] = {} - subject.options[:content_types][:custom] = 'application/x-custom' - _, headers, = subject.call('PATH_INFO' => '/info.custom') - expect(headers['Content-type']).to eq('application/x-custom') + s = described_class.new(app, content_types: { custom: 'application/x-custom' }) + _, headers, = s.call(Rack::PATH_INFO => '/info.custom') + expect(headers[Rack::CONTENT_TYPE]).to eq('application/x-custom') end + it 'is set for vendored with registered type' do - subject.options[:content_types] = {} - subject.options[:content_types][:custom] = 'application/vnd.test+json' - _, headers, = subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json') - expect(headers['Content-type']).to eq('application/vnd.test+json') - end - it 'is set to closest generic for custom vendored/versioned without registered type' do - _, headers, = subject.call('PATH_INFO' => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json') - expect(headers['Content-type']).to eq('application/json') + s = described_class.new(app, content_types: { custom: 'application/vnd.test+json' }) + _, headers, = s.call(Rack::PATH_INFO => '/info', 'HTTP_ACCEPT' => 'application/vnd.test+json') + expect(headers[Rack::CONTENT_TYPE]).to eq('application/vnd.test+json') end end context 'format' do it 'uses custom formatter' do - subject.options[:content_types] = {} - subject.options[:content_types][:custom] = "don't care" - subject.options[:formatters][:custom] = ->(_obj, _env) { 'CUSTOM FORMAT' } - _, _, body = subject.call('PATH_INFO' => '/info.custom') - expect(read_chunks(body)).to eq(['CUSTOM FORMAT']) + s = described_class.new(app, content_types: { custom: "don't care" }, formatters: { custom: ->(_obj, _env) { 'CUSTOM FORMAT' } }) + r = Rack::MockResponse[*s.call(Rack::PATH_INFO => '/info.custom')] + expect(r.body).to eq('CUSTOM FORMAT') end + context 'default' do let(:body) { ['blah'] } + it 'uses default json formatter' do - _, _, body = subject.call('PATH_INFO' => '/info.json') - expect(read_chunks(body)).to eq(['["blah"]']) + r = Rack::MockResponse[*subject.call(Rack::PATH_INFO => '/info.json')] + expect(r.body).to eq(Grape::Json.dump(body)) end end + it 'uses custom json formatter' do subject.options[:formatters][:json] = ->(_obj, _env) { 'CUSTOM JSON FORMAT' } - _, _, body = subject.call('PATH_INFO' => '/info.json') - expect(read_chunks(body)).to eq(['CUSTOM JSON FORMAT']) + r = Rack::MockResponse[*subject.call(Rack::PATH_INFO => '/info.json')] + expect(r.body).to eq('CUSTOM JSON FORMAT') end end context 'no content responses' do - let(:no_content_response) { ->(status) { [status, {}, ['']] } } + let(:no_content_response) { ->(status) { [status, {}, []] } } - STATUSES_WITHOUT_BODY = if Gem::Version.new(Rack.release) >= Gem::Version.new('2.1.0') + statuses_without_body = if Gem::Version.new(Rack.release) >= Gem::Version.new('2.1.0') Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.keys else Rack::Utils::STATUS_WITH_NO_ENTITY_BODY end - STATUSES_WITHOUT_BODY.each do |status| + statuses_without_body.each do |status| it "does not modify a #{status} response" do expected_response = no_content_response[status] allow(app).to receive(:call).and_return(expected_response) @@ -231,6 +261,7 @@ def to_xml end context 'input' do + content_types = ['application/json', 'application/json; charset=utf-8'].freeze %w[POST PATCH PUT DELETE].each do |method| context 'when body is not nil or empty' do context 'when Content-Type is supported' do @@ -239,14 +270,14 @@ def to_xml it "parses the body from #{method} and copies values into rack.request.form_hash" do subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => content_type, - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) - expect(subject.env['rack.request.form_hash']['is_boolean']).to be true - expect(subject.env['rack.request.form_hash']['string']).to eq('thing') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') end end @@ -257,11 +288,11 @@ def to_xml it 'returns a 415 HTTP error status' do error = catch(:error) do subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => content_type, - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) end expect(error[:status]).to eq(415) @@ -272,107 +303,112 @@ def to_xml context 'when body is nil' do let(:io) { double } + before do - allow(io).to receive_message_chain(:rewind, :read).and_return(nil) + allow(io).to receive_message_chain(rewind: nil, read: nil) end it 'does not read and parse the body' do expect(subject).not_to receive(:read_rack_input) subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => 'application/json', - 'rack.input' => io, - 'CONTENT_LENGTH' => 0 + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => '0' ) end end context 'when body is empty' do let(:io) { double } + before do - allow(io).to receive_message_chain(:rewind, :read).and_return('') + allow(io).to receive_messages(rewind: nil, read: '') end it 'does not read and parse the body' do expect(subject).not_to receive(:read_rack_input) subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => 'application/json', - 'rack.input' => io, + Rack::RACK_INPUT => io, 'CONTENT_LENGTH' => 0 ) end end - ['application/json', 'application/json; charset=utf-8'].each do |content_type| + content_types.each do |content_type| context content_type do it "parses the body from #{method} and copies values into rack.request.form_hash" do io = StringIO.new('{"is_boolean":true,"string":"thing"}') subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => content_type, - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) - expect(subject.env['rack.request.form_hash']['is_boolean']).to be true - expect(subject.env['rack.request.form_hash']['string']).to eq('thing') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') end end end it "parses the chunked body from #{method} and copies values into rack.request.from_hash" do io = StringIO.new('{"is_boolean":true,"string":"thing"}') subject.call( - 'PATH_INFO' => '/infol', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/infol', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => 'application/json', - 'rack.input' => io, + Rack::RACK_INPUT => io, 'HTTP_TRANSFER_ENCODING' => 'chunked' ) - expect(subject.env['rack.request.form_hash']['is_boolean']).to be true - expect(subject.env['rack.request.form_hash']['string']).to eq('thing') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') end + it 'rewinds IO' do io = StringIO.new('{"is_boolean":true,"string":"thing"}') io.read subject.call( - 'PATH_INFO' => '/infol', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/infol', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => 'application/json', - 'rack.input' => io, + Rack::RACK_INPUT => io, 'HTTP_TRANSFER_ENCODING' => 'chunked' ) - expect(subject.env['rack.request.form_hash']['is_boolean']).to be true - expect(subject.env['rack.request.form_hash']['string']).to eq('thing') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['is_boolean']).to be true + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['string']).to eq('thing') end + it "parses the body from an xml #{method} and copies values into rack.request.from_hash" do io = StringIO.new('Test') subject.call( - 'PATH_INFO' => '/info.xml', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info.xml', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => 'application/xml', - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) if Object.const_defined? :MultiXml - expect(subject.env['rack.request.form_hash']['thing']['name']).to eq('Test') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['thing']['name']).to eq('Test') else - expect(subject.env['rack.request.form_hash']['thing']['name']['__content__']).to eq('Test') + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]['thing']['name']['__content__']).to eq('Test') end end + [Rack::Request::FORM_DATA_MEDIA_TYPES, Rack::Request::PARSEABLE_DATA_MEDIA_TYPES].flatten.each do |content_type| it "ignores #{content_type}" do io = StringIO.new('name=Other+Test+Thing') subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => method, + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => method, 'CONTENT_TYPE' => content_type, - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) - expect(subject.env['rack.request.form_hash']).to be_nil + expect(subject.env[Rack::RACK_REQUEST_FORM_HASH]).to be_nil end end end @@ -382,43 +418,52 @@ def to_xml let(:file) { double(File) } let(:file_body) { Grape::ServeStream::StreamResponse.new(file) } let(:app) { ->(_env) { [200, {}, file_body] } } + let(:body) { 'data' } + let(:env) do + { Rack::PATH_INFO => '/somewhere', 'HTTP_ACCEPT' => 'application/json' } + end + let(:headers) do + if Gem::Version.new(Rack.release) < Gem::Version.new('3.1') + { Rack::CONTENT_TYPE => 'application/json', Rack::CONTENT_LENGTH => body.bytesize.to_s } + else + { Rack::CONTENT_TYPE => 'application/json' } + end + end it 'returns a file response' do - expect(file).to receive(:each).and_yield('data') - env = { 'PATH_INFO' => '/somewhere', 'HTTP_ACCEPT' => 'application/json' } - status, headers, body = subject.call(env) - expect(status).to be == 200 - expect(headers).to be == { 'Content-Type' => 'application/json' } - expect(read_chunks(body)).to be == ['data'] + expect(file).to receive(:each).and_yield(body) + r = Rack::MockResponse[*subject.call(env)] + expect(r).to be_successful + expect(r.headers).to eq(headers) + expect(r.body).to eq('data') end end context 'inheritable formatters' do - class InvalidFormatter - def self.call(_, _) - { message: 'invalid' }.to_json + subject { described_class.new(app, formatters: { invalid: invalid_formatter }, content_types: { invalid: 'application/x-invalid' }) } + + let(:invalid_formatter) do + Class.new do + def self.call(_, _) + { message: 'invalid' }.to_json + end end end + let(:app) { ->(_env) { [200, {}, ['']] } } - before do - Grape::Formatter.register :invalid, InvalidFormatter - Grape::ContentTypes.register :invalid, 'application/x-invalid' - end - after do - Grape::ContentTypes.default_elements.delete(:invalid) - Grape::Formatter.default_elements.delete(:invalid) + let(:env) do + Rack::MockRequest.env_for('/hello.invalid', 'HTTP_ACCEPT' => 'application/x-invalid') end it 'returns response by invalid formatter' do - env = { 'PATH_INFO' => '/hello.invalid', 'HTTP_ACCEPT' => 'application/x-invalid' } - _, _, body = *subject.call(env) - expect(read_chunks(body).join).to eq({ message: 'invalid' }.to_json) + r = Rack::MockResponse[*subject.call(env)] + expect(JSON.parse(r.body)).to eq('message' => 'invalid') end end context 'custom parser raises exception and rescue options are enabled for backtrace and original_exception' do it 'adds the backtrace and original_exception to the error output' do - subject = Grape::Middleware::Formatter.new( + subject = described_class.new( app, rescue_options: { backtrace: true, original_exception: true }, parsers: { json: ->(_object, _env) { raise StandardError, 'fail' } } @@ -426,11 +471,11 @@ def self.call(_, _) io = StringIO.new('{invalid}') error = catch(:error) do subject.call( - 'PATH_INFO' => '/info', - 'REQUEST_METHOD' => 'POST', + Rack::PATH_INFO => '/info', + Rack::REQUEST_METHOD => Rack::POST, 'CONTENT_TYPE' => 'application/json', - 'rack.input' => io, - 'CONTENT_LENGTH' => io.length + Rack::RACK_INPUT => io, + 'CONTENT_LENGTH' => io.length.to_s ) end diff --git a/spec/grape/middleware/globals_spec.rb b/spec/grape/middleware/globals_spec.rb index e50eff171..18d3e962b 100644 --- a/spec/grape/middleware/globals_spec.rb +++ b/spec/grape/middleware/globals_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Globals do - subject { Grape::Middleware::Globals.new(blank_app) } + subject { described_class.new(blank_app) } + before { allow(subject).to receive(:dup).and_return(subject) } let(:blank_app) { ->(_env) { [200, {}, 'Hi there.'] } } @@ -13,17 +12,19 @@ end context 'environment' do - it 'should set the grape.request environment' do + it 'sets the grape.request environment' do subject.call({}) - expect(subject.env['grape.request']).to be_a(Grape::Request) + expect(subject.env[Grape::Env::GRAPE_REQUEST]).to be_a(Grape::Request) end - it 'should set the grape.request.headers environment' do + + it 'sets the grape.request.headers environment' do subject.call({}) - expect(subject.env['grape.request.headers']).to be_a(Hash) + expect(subject.env[Grape::Env::GRAPE_REQUEST_HEADERS]).to be_a(Hash) end - it 'should set the grape.request.params environment' do - subject.call('QUERY_STRING' => 'test=1', 'rack.input' => StringIO.new) - expect(subject.env['grape.request.params']).to be_a(Hash) + + it 'sets the grape.request.params environment' do + subject.call(Rack::QUERY_STRING => 'test=1', Rack::RACK_INPUT => StringIO.new) + expect(subject.env[Grape::Env::GRAPE_REQUEST_PARAMS]).to be_a(Hash) end end end diff --git a/spec/grape/middleware/stack_spec.rb b/spec/grape/middleware/stack_spec.rb index 3833337e4..d5d503a17 100644 --- a/spec/grape/middleware/stack_spec.rb +++ b/spec/grape/middleware/stack_spec.rb @@ -1,47 +1,44 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Stack do - module StackSpec - class FooMiddleware; end - class BarMiddleware; end - class BlockMiddleware + subject { described_class.new } + + let(:foo_middleware) { Class.new } + let(:bar_middleware) { Class.new } + let(:block_middleware) do + Class.new do attr_reader :block + def initialize(&block) @block = block end end end - - let(:proc) { ->() {} } - let(:others) { [[:use, StackSpec::BarMiddleware], [:insert_before, StackSpec::BarMiddleware, StackSpec::BlockMiddleware, proc]] } - - subject { Grape::Middleware::Stack.new } + let(:proc) { -> {} } + let(:others) { [[:use, bar_middleware], [:insert_before, bar_middleware, block_middleware, proc]] } before do - subject.use StackSpec::FooMiddleware + subject.use foo_middleware end describe '#use' do it 'pushes a middleware class onto the stack' do - expect { subject.use StackSpec::BarMiddleware } - .to change { subject.size }.by(1) - expect(subject.last).to eq(StackSpec::BarMiddleware) + expect { subject.use bar_middleware } + .to change(subject, :size).by(1) + expect(subject.last).to eq(bar_middleware) end it 'pushes a middleware class with arguments onto the stack' do - expect { subject.use StackSpec::BarMiddleware, false, my_arg: 42 } - .to change { subject.size }.by(1) - expect(subject.last).to eq(StackSpec::BarMiddleware) - expect(subject.last.args).to eq([false]) - expect(subject.last.opts).to eq(my_arg: 42) + expect { subject.use bar_middleware, false, my_arg: 42 } + .to change(subject, :size).by(1) + expect(subject.last).to eq(bar_middleware) + expect(subject.last.args).to eq([false, { my_arg: 42 }]) end it 'pushes a middleware class with block arguments onto the stack' do - expect { subject.use StackSpec::BlockMiddleware, &proc } - .to change { subject.size }.by(1) - expect(subject.last).to eq(StackSpec::BlockMiddleware) + expect { subject.use block_middleware, &proc } + .to change(subject, :size).by(1) + expect(subject.last).to eq(block_middleware) expect(subject.last.args).to eq([]) expect(subject.last.block).to eq(proc) end @@ -49,57 +46,59 @@ def initialize(&block) describe '#insert' do it 'inserts a middleware class at the integer index' do - expect { subject.insert 0, StackSpec::BarMiddleware } - .to change { subject.size }.by(1) - expect(subject[0]).to eq(StackSpec::BarMiddleware) - expect(subject[1]).to eq(StackSpec::FooMiddleware) + expect { subject.insert 0, bar_middleware } + .to change(subject, :size).by(1) + expect(subject[0]).to eq(bar_middleware) + expect(subject[1]).to eq(foo_middleware) end end describe '#insert_before' do it 'inserts a middleware before another middleware class' do - expect { subject.insert_before StackSpec::FooMiddleware, StackSpec::BarMiddleware } - .to change { subject.size }.by(1) - expect(subject[0]).to eq(StackSpec::BarMiddleware) - expect(subject[1]).to eq(StackSpec::FooMiddleware) + expect { subject.insert_before foo_middleware, bar_middleware } + .to change(subject, :size).by(1) + expect(subject[0]).to eq(bar_middleware) + expect(subject[1]).to eq(foo_middleware) end it 'inserts a middleware before an anonymous class given by its superclass' do - subject.use Class.new(StackSpec::BlockMiddleware) + subject.use Class.new(block_middleware) - expect { subject.insert_before StackSpec::BlockMiddleware, StackSpec::BarMiddleware } - .to change { subject.size }.by(1) + expect { subject.insert_before block_middleware, bar_middleware } + .to change(subject, :size).by(1) - expect(subject[1]).to eq(StackSpec::BarMiddleware) - expect(subject[2]).to eq(StackSpec::BlockMiddleware) + expect(subject[1]).to eq(bar_middleware) + expect(subject[2]).to eq(block_middleware) end it 'raises an error on an invalid index' do - expect { subject.insert_before StackSpec::BlockMiddleware, StackSpec::BarMiddleware } + stub_const('StackSpec::BlockMiddleware', block_middleware) + expect { subject.insert_before block_middleware, bar_middleware } .to raise_error(RuntimeError, 'No such middleware to insert before: StackSpec::BlockMiddleware') end end describe '#insert_after' do it 'inserts a middleware after another middleware class' do - expect { subject.insert_after StackSpec::FooMiddleware, StackSpec::BarMiddleware } - .to change { subject.size }.by(1) - expect(subject[1]).to eq(StackSpec::BarMiddleware) - expect(subject[0]).to eq(StackSpec::FooMiddleware) + expect { subject.insert_after foo_middleware, bar_middleware } + .to change(subject, :size).by(1) + expect(subject[1]).to eq(bar_middleware) + expect(subject[0]).to eq(foo_middleware) end it 'inserts a middleware after an anonymous class given by its superclass' do - subject.use Class.new(StackSpec::BlockMiddleware) + subject.use Class.new(block_middleware) - expect { subject.insert_after StackSpec::BlockMiddleware, StackSpec::BarMiddleware } - .to change { subject.size }.by(1) + expect { subject.insert_after block_middleware, bar_middleware } + .to change(subject, :size).by(1) - expect(subject[1]).to eq(StackSpec::BlockMiddleware) - expect(subject[2]).to eq(StackSpec::BarMiddleware) + expect(subject[1]).to eq(block_middleware) + expect(subject[2]).to eq(bar_middleware) end it 'raises an error on an invalid index' do - expect { subject.insert_after StackSpec::BlockMiddleware, StackSpec::BarMiddleware } + stub_const('StackSpec::BlockMiddleware', block_middleware) + expect { subject.insert_after block_middleware, bar_middleware } .to raise_error(RuntimeError, 'No such middleware to insert after: StackSpec::BlockMiddleware') end end @@ -107,17 +106,17 @@ def initialize(&block) describe '#merge_with' do it 'applies a collection of operations and middlewares' do expect { subject.merge_with(others) } - .to change { subject.size }.by(2) - expect(subject[0]).to eq(StackSpec::FooMiddleware) - expect(subject[1]).to eq(StackSpec::BlockMiddleware) - expect(subject[2]).to eq(StackSpec::BarMiddleware) + .to change(subject, :size).by(2) + expect(subject[0]).to eq(foo_middleware) + expect(subject[1]).to eq(block_middleware) + expect(subject[2]).to eq(bar_middleware) end context 'middleware spec with proc declaration exists' do - let(:middleware_spec_with_proc) { [:use, StackSpec::FooMiddleware, proc] } + let(:middleware_spec_with_proc) { [:use, foo_middleware, proc] } it 'properly forwards spec arguments' do - expect(subject).to receive(:use).with(StackSpec::FooMiddleware) + expect(subject).to receive(:use).with(foo_middleware) subject.merge_with([middleware_spec_with_proc]) end end @@ -129,15 +128,15 @@ def initialize(&block) end context 'when @others are present' do - let(:others) { [[:insert_after, Grape::Middleware::Formatter, StackSpec::BarMiddleware]] } + let(:others) { [[:insert_after, Grape::Middleware::Formatter, bar_middleware]] } it 'applies the middleware specs stored in @others' do subject.concat others subject.use Grape::Middleware::Formatter subject.build - expect(subject[0]).to eq StackSpec::FooMiddleware + expect(subject[0]).to eq foo_middleware expect(subject[1]).to eq Grape::Middleware::Formatter - expect(subject[2]).to eq StackSpec::BarMiddleware + expect(subject[2]).to eq bar_middleware end end end @@ -148,7 +147,7 @@ def initialize(&block) end it 'calls +merge_with+ with the :use specs' do - expect(subject).to receive(:merge_with).with [[:use, StackSpec::BarMiddleware]] + expect(subject).to receive(:merge_with).with [[:use, bar_middleware]] subject.concat others end end diff --git a/spec/grape/middleware/versioner/accept_version_header_spec.rb b/spec/grape/middleware/versioner/accept_version_header_spec.rb index f13d44175..7693cb92d 100644 --- a/spec/grape/middleware/versioner/accept_version_header_spec.rb +++ b/spec/grape/middleware/versioner/accept_version_header_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Versioner::AcceptVersionHeader do + subject { described_class.new(app, @options) } + let(:app) { ->(env) { [200, env, env] } } - subject { Grape::Middleware::Versioner::AcceptVersionHeader.new(app, **(@options || {})) } before do @options = { @@ -14,6 +13,18 @@ } end + describe '#bad encoding' do + before do + @options[:versions] = %w[v1] + end + + it 'does not raise an error' do + expect do + subject.call('HTTP_ACCEPT_VERSION' => "\x80") + end.to throw_symbol(:error, status: 406, headers: { 'X-Cascade' => 'pass' }, message: 'The requested version is not supported.') + end + end + context 'api.version' do before do @options[:versions] = ['v1'] @@ -21,13 +32,13 @@ it 'is set' do status, _, env = subject.call('HTTP_ACCEPT_VERSION' => 'v1') - expect(env['api.version']).to eql 'v1' + expect(env[Grape::Env::API_VERSION]).to eql 'v1' expect(status).to eq(200) end it 'is set if format provided' do status, _, env = subject.call('HTTP_ACCEPT_VERSION' => 'v1') - expect(env['api.version']).to eql 'v1' + expect(env[Grape::Env::API_VERSION]).to eql 'v1' expect(status).to eq(200) end diff --git a/spec/grape/middleware/versioner/header_spec.rb b/spec/grape/middleware/versioner/header_spec.rb index b2349a712..230133cba 100644 --- a/spec/grape/middleware/versioner/header_spec.rb +++ b/spec/grape/middleware/versioner/header_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Versioner::Header do + subject { described_class.new(app, @options) } + let(:app) { ->(env) { [200, env, env] } } - subject { Grape::Middleware::Versioner::Header.new(app, **(@options || {})) } before do @options = { @@ -18,22 +17,22 @@ context 'api.type and api.subtype' do it 'sets type and subtype to first choice of content type if no preference given' do status, _, env = subject.call('HTTP_ACCEPT' => '*/*') - expect(env['api.type']).to eql 'application' - expect(env['api.subtype']).to eql 'vnd.vendor+xml' + expect(env[Grape::Env::API_TYPE]).to eql 'application' + expect(env[Grape::Env::API_SUBTYPE]).to eql 'vnd.vendor+xml' expect(status).to eq(200) end it 'sets preferred type' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/*') - expect(env['api.type']).to eql 'application' - expect(env['api.subtype']).to eql 'vnd.vendor+xml' + expect(env[Grape::Env::API_TYPE]).to eql 'application' + expect(env[Grape::Env::API_SUBTYPE]).to eql 'vnd.vendor+xml' expect(status).to eq(200) end it 'sets preferred type and subtype' do status, _, env = subject.call('HTTP_ACCEPT' => 'text/plain') - expect(env['api.type']).to eql 'text' - expect(env['api.subtype']).to eql 'plain' + expect(env[Grape::Env::API_TYPE]).to eql 'text' + expect(env[Grape::Env::API_SUBTYPE]).to eql 'plain' expect(status).to eq(200) end end @@ -41,13 +40,13 @@ context 'api.format' do it 'is set' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor+json') - expect(env['api.format']).to eql 'json' + expect(env[Grape::Env::API_FORMAT]).to eql 'json' expect(status).to eq(200) end it 'is nil if not provided' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor') - expect(env['api.format']).to eql nil + expect(env[Grape::Env::API_FORMAT]).to be_nil expect(status).to eq(200) end @@ -59,13 +58,13 @@ it 'is set' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1+json') - expect(env['api.format']).to eql 'json' + expect(env[Grape::Env::API_FORMAT]).to eql 'json' expect(status).to eq(200) end it 'is nil if not provided' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1') - expect(env['api.format']).to eql nil + expect(env[Grape::Env::API_FORMAT]).to be_nil expect(status).to eq(200) end end @@ -75,13 +74,13 @@ context 'api.vendor' do it 'is set' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor') - expect(env['api.vendor']).to eql 'vendor' + expect(env[Grape::Env::API_VENDOR]).to eql 'vendor' expect(status).to eq(200) end it 'is set if format provided' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor+json') - expect(env['api.vendor']).to eql 'vendor' + expect(env[Grape::Env::API_VENDOR]).to eql 'vendor' expect(status).to eq(200) end @@ -90,7 +89,7 @@ .to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include 'API vendor not found' end end @@ -102,13 +101,13 @@ it 'is set' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1') - expect(env['api.vendor']).to eql 'vendor' + expect(env[Grape::Env::API_VENDOR]).to eql 'vendor' expect(status).to eq(200) end it 'is set if format provided' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1+json') - expect(env['api.vendor']).to eql 'vendor' + expect(env[Grape::Env::API_VENDOR]).to eql 'vendor' expect(status).to eq(200) end @@ -117,7 +116,7 @@ .to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('API vendor not found') end end @@ -131,13 +130,13 @@ it 'is set' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1') - expect(env['api.version']).to eql 'v1' + expect(env[Grape::Env::API_VERSION]).to eql 'v1' expect(status).to eq(200) end it 'is set if format provided' do status, _, env = subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v1+json') - expect(env['api.version']).to eql 'v1' + expect(env[Grape::Env::API_VERSION]).to eql 'v1' expect(status).to eq(200) end @@ -145,7 +144,7 @@ expect { subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v2+json').last }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidVersionHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('API version not found') end end @@ -178,7 +177,7 @@ expect { subject.call({}).last }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('Accept header must be set.') end end @@ -187,7 +186,7 @@ expect { subject.call('HTTP_ACCEPT' => '').last }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('Accept header must be set.') end end @@ -208,7 +207,7 @@ expect { subject.call({}).last }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql({}) - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('Accept header must be set.') end end @@ -218,7 +217,7 @@ .to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql({}) - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('API vendor or version not found.') end end @@ -227,7 +226,7 @@ expect { subject.call('HTTP_ACCEPT' => '').last }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql({}) - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('Accept header must be set.') end end @@ -237,7 +236,7 @@ .to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidAcceptHeader) expect(exception.headers).to eql({}) - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('API vendor or version not found.') end end @@ -264,7 +263,7 @@ expect { subject.call('HTTP_ACCEPT' => 'application/vnd.vendor-v3+json') }.to raise_exception do |exception| expect(exception).to be_a(Grape::Exceptions::InvalidVersionHeader) expect(exception.headers).to eql('X-Cascade' => 'pass') - expect(exception.status).to eql 406 + expect(exception.status).to be 406 expect(exception.message).to include('API version not found') end end @@ -327,4 +326,20 @@ def app end end end + + context 'with missing vendor option' do + subject do + Class.new(Grape::API) do + version 'v1', using: :header + end + end + + def app + subject + end + + it 'fails' do + expect { versioned_get '/', 'v1', using: :header }.to raise_error Grape::Exceptions::MissingVendorOption + end + end end diff --git a/spec/grape/middleware/versioner/param_spec.rb b/spec/grape/middleware/versioner/param_spec.rb index 328696128..4e6bb4aa7 100644 --- a/spec/grape/middleware/versioner/param_spec.rb +++ b/spec/grape/middleware/versioner/param_spec.rb @@ -1,22 +1,21 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Versioner::Param do - let(:app) { ->(env) { [200, env, env['api.version']] } } + subject { described_class.new(app, options) } + + let(:app) { ->(env) { [200, env, env[Grape::Env::API_VERSION]] } } let(:options) { {} } - subject { Grape::Middleware::Versioner::Param.new(app, **options) } it 'sets the API version based on the default param (apiver)' do env = Rack::MockRequest.env_for('/awesome', params: { 'apiver' => 'v1' }) - expect(subject.call(env)[1]['api.version']).to eq('v1') + expect(subject.call(env)[1][Grape::Env::API_VERSION]).to eq('v1') end it 'cuts (only) the version out of the params' do env = Rack::MockRequest.env_for('/awesome', params: { 'apiver' => 'v1', 'other_param' => '5' }) - env['rack.request.query_hash'] = Rack::Utils.parse_nested_query(env['QUERY_STRING']) - expect(subject.call(env)[1]['rack.request.query_hash']['apiver']).to be_nil - expect(subject.call(env)[1]['rack.request.query_hash']['other_param']).to eq('5') + env[Rack::RACK_REQUEST_QUERY_HASH] = Rack::Utils.parse_nested_query(env[Rack::QUERY_STRING]) + expect(subject.call(env)[1][Rack::RACK_REQUEST_QUERY_HASH]['apiver']).to be_nil + expect(subject.call(env)[1][Rack::RACK_REQUEST_QUERY_HASH]['other_param']).to eq('5') end it 'provides a nil version if no version is given' do @@ -26,25 +25,29 @@ context 'with specified parameter name' do let(:options) { { version_options: { parameter: 'v' } } } + it 'sets the API version based on the custom parameter name' do env = Rack::MockRequest.env_for('/awesome', params: { 'v' => 'v1' }) - expect(subject.call(env)[1]['api.version']).to eq('v1') + expect(subject.call(env)[1][Grape::Env::API_VERSION]).to eq('v1') end + it 'does not set the API version based on the default param' do env = Rack::MockRequest.env_for('/awesome', params: { 'apiver' => 'v1' }) - expect(subject.call(env)[1]['api.version']).to be_nil + expect(subject.call(env)[1][Grape::Env::API_VERSION]).to be_nil end end context 'with specified versions' do let(:options) { { versions: %w[v1 v2] } } + it 'throws an error if a non-allowed version is specified' do env = Rack::MockRequest.env_for('/awesome', params: { 'apiver' => 'v3' }) expect(catch(:error) { subject.call(env) }[:status]).to eq(404) end + it 'allows versions that have been specified' do env = Rack::MockRequest.env_for('/awesome', params: { 'apiver' => 'v1' }) - expect(subject.call(env)[1]['api.version']).to eq('v1') + expect(subject.call(env)[1][Grape::Env::API_VERSION]).to eq('v1') end end @@ -55,6 +58,7 @@ version_options: { using: :header } } end + it 'returns a 200 (matches the first version found)' do env = Rack::MockRequest.env_for('/awesome', params: {}) expect(subject.call(env).first).to eq(200) diff --git a/spec/grape/middleware/versioner/path_spec.rb b/spec/grape/middleware/versioner/path_spec.rb index e703931f3..4593eca03 100644 --- a/spec/grape/middleware/versioner/path_spec.rb +++ b/spec/grape/middleware/versioner/path_spec.rb @@ -1,32 +1,32 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Versioner::Path do - let(:app) { ->(env) { [200, env, env['api.version']] } } + subject { described_class.new(app, options) } + + let(:app) { ->(env) { [200, env, env[Grape::Env::API_VERSION]] } } let(:options) { {} } - subject { Grape::Middleware::Versioner::Path.new(app, **options) } it 'sets the API version based on the first path' do - expect(subject.call('PATH_INFO' => '/v1/awesome').last).to eq('v1') + expect(subject.call(Rack::PATH_INFO => '/v1/awesome').last).to eq('v1') end it 'does not cut the version out of the path' do - expect(subject.call('PATH_INFO' => '/v1/awesome')[1]['PATH_INFO']).to eq('/v1/awesome') + expect(subject.call(Rack::PATH_INFO => '/v1/awesome')[1][Rack::PATH_INFO]).to eq('/v1/awesome') end it 'provides a nil version if no path is given' do - expect(subject.call('PATH_INFO' => '/').last).to be_nil + expect(subject.call(Rack::PATH_INFO => '/').last).to be_nil end context 'with a pattern' do let(:options) { { pattern: /v./i } } + it 'sets the version if it matches' do - expect(subject.call('PATH_INFO' => '/v1/awesome').last).to eq('v1') + expect(subject.call(Rack::PATH_INFO => '/v1/awesome').last).to eq('v1') end it 'ignores the version if it fails to match' do - expect(subject.call('PATH_INFO' => '/awesome/radical').last).to be_nil + expect(subject.call(Rack::PATH_INFO => '/awesome/radical').last).to be_nil end end @@ -35,26 +35,28 @@ let(:options) { { versions: versions } } it 'throws an error if a non-allowed version is specified' do - expect(catch(:error) { subject.call('PATH_INFO' => '/v3/awesome') }[:status]).to eq(404) + expect(catch(:error) { subject.call(Rack::PATH_INFO => '/v3/awesome') }[:status]).to eq(404) end it 'allows versions that have been specified' do - expect(subject.call('PATH_INFO' => '/v1/asoasd').last).to eq('v1') + expect(subject.call(Rack::PATH_INFO => '/v1/asoasd').last).to eq('v1') end end end context 'with prefix, but requested version is not matched' do let(:options) { { prefix: '/v1', pattern: /v./i } } + it 'recognizes potential version' do - expect(subject.call('PATH_INFO' => '/v3/foo').last).to eq('v3') + expect(subject.call(Rack::PATH_INFO => '/v3/foo').last).to eq('v3') end end context 'with mount path' do let(:options) { { mount_path: '/mounted', versions: [:v1] } } + it 'recognizes potential version' do - expect(subject.call('PATH_INFO' => '/mounted/v1/foo').last).to eq('v1') + expect(subject.call(Rack::PATH_INFO => '/mounted/v1/foo').last).to eq('v1') end end end diff --git a/spec/grape/middleware/versioner_spec.rb b/spec/grape/middleware/versioner_spec.rb index 198f15a3a..eb7b95b3c 100644 --- a/spec/grape/middleware/versioner_spec.rb +++ b/spec/grape/middleware/versioner_spec.rb @@ -1,23 +1,37 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Middleware::Versioner do - let(:klass) { Grape::Middleware::Versioner } + subject { described_class.using(strategy) } + + context 'when :path' do + let(:strategy) { :path } + + it { is_expected.to eq(Grape::Middleware::Versioner::Path) } + end - it 'recognizes :path' do - expect(klass.using(:path)).to eq(Grape::Middleware::Versioner::Path) + context 'when :header' do + let(:strategy) { :header } + + it { is_expected.to eq(Grape::Middleware::Versioner::Header) } end - it 'recognizes :header' do - expect(klass.using(:header)).to eq(Grape::Middleware::Versioner::Header) + context 'when :param' do + let(:strategy) { :param } + + it { is_expected.to eq(Grape::Middleware::Versioner::Param) } end - it 'recognizes :param' do - expect(klass.using(:param)).to eq(Grape::Middleware::Versioner::Param) + context 'when :accept_version_header' do + let(:strategy) { :accept_version_header } + + it { is_expected.to eq(Grape::Middleware::Versioner::AcceptVersionHeader) } end - it 'recognizes :accept_version_header' do - expect(klass.using(:accept_version_header)).to eq(Grape::Middleware::Versioner::AcceptVersionHeader) + context 'when unknown' do + let(:strategy) { :unknown } + + it 'raises an error' do + expect { subject }.to raise_error Grape::Exceptions::InvalidVersionerOption, Grape::Exceptions::InvalidVersionerOption.new(strategy).message + end end end diff --git a/spec/grape/named_api_spec.rb b/spec/grape/named_api_spec.rb index 145066612..77e73d875 100644 --- a/spec/grape/named_api_spec.rb +++ b/spec/grape/named_api_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' - -describe 'A named API' do +describe Grape::API do subject(:api_name) { NamedAPI.endpoints.last.options[:for].to_s } let(:api) do @@ -13,9 +11,11 @@ end end - before { stub_const('NamedAPI', api) } + let(:name) { 'NamedAPI' } + + before { stub_const(name, api) } it 'can access the name of the API' do - expect(api_name).to eq 'NamedAPI' + expect(api_name).to eq name end end diff --git a/spec/grape/params_builder/hash_spec.rb b/spec/grape/params_builder/hash_spec.rb new file mode 100644 index 000000000..7b185f34b --- /dev/null +++ b/spec/grape/params_builder/hash_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +describe Grape::ParamsBuilder::Hash do + subject { app } + + let(:app) do + Class.new(Grape::API) + end + + describe 'in an endpoint' do + describe '#params' do + before do + subject.params do + build_with :hash + end + + subject.get do + params.class + end + end + + it 'is of type Hash' do + get '/' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('Hash') + end + end + end + + describe 'in an api' do + before do + subject.build_with :hash + end + + describe '#params' do + before do + subject.get do + params.class + end + end + + it 'is Hash' do + get '/' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('Hash') + end + end + + it 'symbolizes params keys' do + subject.params do + optional :a, type: Hash do + optional :b, type: Hash do + optional :c, type: String + end + optional :d, type: Array + end + end + + subject.get '/foo' do + [params[:a][:b][:c], params[:a][:d]] + end + + get '/foo', 'a' => { b: { c: 'bar' }, 'd' => ['foo'] } + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", ["foo"]]') + end + + it 'symbolizes the params' do + subject.params do + build_with :hash + requires :a, type: String + end + + subject.get '/foo' do + [params[:a], params['a']] + end + + get '/foo', a: 'bar' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", nil]') + end + + it 'does not overwrite route_param with a regular param if they have same name' do + subject.namespace :route_param do + route_param :foo do + get { params.to_json } + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('{"foo":"bar"}') + end + + it 'does not overwrite route_param with a defined regular param if they have same name' do + subject.namespace :route_param do + params do + requires :foo, type: String + end + route_param :foo do + get do + params[:foo] + end + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('bar') + end + end +end diff --git a/spec/grape/params_builder/hash_with_indifferent_access_spec.rb b/spec/grape/params_builder/hash_with_indifferent_access_spec.rb new file mode 100644 index 000000000..ee0fd1ecc --- /dev/null +++ b/spec/grape/params_builder/hash_with_indifferent_access_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +describe Grape::ParamsBuilder::HashWithIndifferentAccess do + subject { app } + + let(:app) do + Class.new(Grape::API) + end + + describe 'in an endpoint' do + describe '#params' do + before do + subject.params do + build_with :hash_with_indifferent_access + end + + subject.get do + params.class + end + end + + it 'is of type Hash' do + get '/' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('ActiveSupport::HashWithIndifferentAccess') + end + end + end + + describe 'in an api' do + before do + subject.build_with :hash_with_indifferent_access + end + + describe '#params' do + before do + subject.get do + params.class + end + end + + it 'is a Hash' do + get '/' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('ActiveSupport::HashWithIndifferentAccess') + end + + it 'parses sub hash params' do + subject.params do + build_with :hash_with_indifferent_access + + optional :a, type: Hash do + optional :b, type: Hash do + optional :c, type: String + end + optional :d, type: Array + end + end + + subject.get '/foo' do + [params[:a]['b'][:c], params['a'][:d]] + end + + get '/foo', a: { b: { c: 'bar' }, d: ['foo'] } + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", ["foo"]]') + end + + it 'params are indifferent to symbol or string keys' do + subject.params do + build_with :hash_with_indifferent_access + optional :a, type: Hash do + optional :b, type: Hash do + optional :c, type: String + end + optional :d, type: Array + end + end + + subject.get '/foo' do + [params[:a]['b'][:c], params['a'][:d]] + end + + get '/foo', 'a' => { b: { c: 'bar' }, 'd' => ['foo'] } + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", ["foo"]]') + end + + it 'responds to string keys' do + subject.params do + build_with :hash_with_indifferent_access + requires :a, type: String + end + + subject.get '/foo' do + [params[:a], params['a']] + end + + get '/foo', a: 'bar' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", "bar"]') + end + end + + it 'does not overwrite route_param with a regular param if they have same name' do + subject.namespace :route_param do + route_param :foo do + get { params.to_json } + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('{"foo":"bar"}') + end + + it 'does not overwrite route_param with a defined regular param if they have same name' do + subject.namespace :route_param do + params do + requires :foo, type: String + end + route_param :foo do + get do + [params[:foo], params['foo']] + end + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('["bar", "bar"]') + end + end +end diff --git a/spec/grape/parser_spec.rb b/spec/grape/parser_spec.rb index ace8954fa..a349d61e7 100644 --- a/spec/grape/parser_spec.rb +++ b/spec/grape/parser_spec.rb @@ -1,77 +1,22 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Parser do subject { described_class } - describe '.builtin_parsers' do - it 'returns an instance of Hash' do - expect(subject.builtin_parsers).to be_an_instance_of(Hash) - end - - it 'includes json and xml parsers by default' do - expect(subject.builtin_parsers).to include(json: Grape::Parser::Json, xml: Grape::Parser::Xml) - end - end - - describe '.parsers' do - it 'returns an instance of Hash' do - expect(subject.parsers(**{})).to be_an_instance_of(Hash) - end - - it 'includes built-in parsers' do - expect(subject.parsers(**{})).to include(subject.builtin_parsers) - end - - context 'with :parsers option' do - let(:parsers) { { customized: Class.new } } - it 'includes passed :parsers values' do - expect(subject.parsers(parsers: parsers)).to include(parsers) - end - end - - context 'with added parser by using `register` keyword' do - let(:added_parser) { Class.new } - before { subject.register :added, added_parser } - it 'includes added parser' do - expect(subject.parsers(**{})).to include(added: added_parser) - end - end - end - describe '.parser_for' do let(:options) { {} } - it 'calls .parsers' do - expect(subject).to receive(:parsers).with(any_args).and_return(subject.builtin_parsers) - subject.parser_for(:json, **options) - end - it 'returns parser correctly' do expect(subject.parser_for(:json)).to eq(Grape::Parser::Json) end context 'when parser is available' do - before { subject.register :customized_json, Grape::Parser::Json } - it 'returns registered parser if available' do - expect(subject.parser_for(:customized_json)).to eq(Grape::Parser::Json) - end - end - - context 'when parser is an instance of Symbol' do - before do - allow(subject).to receive(:foo).and_return(:bar) - subject.register :foo, :foo + let(:parsers) do + { customized_json: Grape::Parser::Json } end - it 'returns an instance of Method' do - expect(subject.parser_for(:foo)).to be_an_instance_of(Method) - end - - it 'returns object which can be called' do - method = subject.parser_for(:foo) - expect(method.call).to eq(:bar) + it 'returns registered parser if available' do + expect(subject.parser_for(:customized_json, parsers)).to eq(Grape::Parser::Json) end end diff --git a/spec/grape/path_spec.rb b/spec/grape/path_spec.rb index e43e1df4a..9b9b2ed26 100644 --- a/spec/grape/path_spec.rb +++ b/spec/grape/path_spec.rb @@ -1,253 +1,89 @@ # frozen_string_literal: true -require 'spec_helper' - -module Grape - describe Path do - describe '#initialize' do - it 'remembers the path' do - path = Path.new('/:id', anything, anything) - expect(path.raw_path).to eql('/:id') - end - - it 'remembers the namespace' do - path = Path.new(anything, '/users', anything) - expect(path.namespace).to eql('/users') - end - - it 'remebers the settings' do - path = Path.new(anything, anything, foo: 'bar') - expect(path.settings).to eql(foo: 'bar') - end - end - - describe '#mount_path' do - it 'is nil when no mount path setting exists' do - path = Path.new(anything, anything, {}) - expect(path.mount_path).to be_nil - end - - it 'is nil when the mount path is nil' do - path = Path.new(anything, anything, mount_path: nil) - expect(path.mount_path).to be_nil +describe Grape::Path do + describe '#origin' do + context 'mount_path' do + it 'is not included when it is nil' do + path = described_class.new(nil, nil, mount_path: '/foo/bar') + expect(path.origin).to eql '/foo/bar' end - it 'splits the mount path' do - path = Path.new(anything, anything, mount_path: %w[foo bar]) - expect(path.mount_path).to eql(%w[foo bar]) + it 'is included when it is not nil' do + path = described_class.new(nil, nil, {}) + expect(path.origin).to eql('/') end end - describe '#root_prefix' do - it 'is nil when no root prefix setting exists' do - path = Path.new(anything, anything, {}) - expect(path.root_prefix).to be_nil - end - - it 'is nil when the mount path is nil' do - path = Path.new(anything, anything, root_prefix: nil) - expect(path.root_prefix).to be_nil - end - - it 'splits the mount path' do - path = Path.new(anything, anything, root_prefix: 'hello/world') - expect(path.root_prefix).to eql(%w[hello world]) + context 'root_prefix' do + it 'is not included when it is nil' do + path = described_class.new(nil, nil, {}) + expect(path.origin).to eql('/') end - end - - describe '#uses_path_versioning?' do - it 'is false when the version setting is nil' do - path = Path.new(anything, anything, version: nil) - expect(path.uses_path_versioning?).to be false - end - - it 'is false when the version option is header' do - path = Path.new( - anything, - anything, - version: 'v1', - version_options: { using: :header } - ) - - expect(path.uses_path_versioning?).to be false - end - - it 'is true when the version option is path' do - path = Path.new( - anything, - anything, - version: 'v1', - version_options: { using: :path } - ) - - expect(path.uses_path_versioning?).to be true - end - end - - describe '#namespace?' do - it 'is false when the namespace is nil' do - path = Path.new(anything, nil, anything) - expect(path.namespace?).to be_falsey - end - - it 'is false when the namespace starts with whitespace' do - path = Path.new(anything, ' /foo', anything) - expect(path.namespace?).to be_falsey - end - - it 'is false when the namespace is the root path' do - path = Path.new(anything, '/', anything) - expect(path.namespace?).to be false - end - - it 'is true otherwise' do - path = Path.new(anything, '/world', anything) - expect(path.namespace?).to be true - end - end - describe '#path?' do - it 'is false when the path is nil' do - path = Path.new(nil, anything, anything) - expect(path.path?).to be_falsey - end - - it 'is false when the path starts with whitespace' do - path = Path.new(' /foo', anything, anything) - expect(path.path?).to be_falsey - end - - it 'is false when the path is the root path' do - path = Path.new('/', anything, anything) - expect(path.path?).to be false - end - - it 'is true otherwise' do - path = Path.new('/hello', anything, anything) - expect(path.path?).to be true - end - end - - describe '#path' do - context 'mount_path' do - it 'is not included when it is nil' do - path = Path.new(nil, nil, mount_path: '/foo/bar') - expect(path.path).to eql '/foo/bar' - end - - it 'is included when it is not nil' do - path = Path.new(nil, nil, {}) - expect(path.path).to eql('/') - end - end - - context 'root_prefix' do - it 'is not included when it is nil' do - path = Path.new(nil, nil, {}) - expect(path.path).to eql('/') - end - - it 'is included after the mount path' do - path = Path.new( - nil, - nil, - mount_path: '/foo', - root_prefix: '/hello' - ) - - expect(path.path).to eql('/foo/hello') - end - end - - it 'uses the namespace after the mount path and root prefix' do - path = Path.new( + it 'is included after the mount path' do + path = described_class.new( + nil, nil, - 'namespace', mount_path: '/foo', root_prefix: '/hello' ) - expect(path.path).to eql('/foo/hello/namespace') + expect(path.origin).to eql('/foo/hello') end + end - it 'uses the raw path after the namespace' do - path = Path.new( - 'raw_path', - 'namespace', - mount_path: '/foo', - root_prefix: '/hello' - ) + it 'uses the namespace after the mount path and root prefix' do + path = described_class.new( + nil, + 'namespace', + mount_path: '/foo', + root_prefix: '/hello' + ) - expect(path.path).to eql('/foo/hello/namespace/raw_path') - end + expect(path.origin).to eql('/foo/hello/namespace') end - describe '#suffix' do - context 'when using a specific format' do - it 'accepts specified format' do - path = Path.new(nil, nil, {}) - allow(path).to receive(:uses_specific_format?) { true } - allow(path).to receive(:settings) { { format: :json } } + it 'uses the raw path after the namespace' do + path = described_class.new( + 'raw_path', + 'namespace', + mount_path: '/foo', + root_prefix: '/hello' + ) - expect(path.suffix).to eql('(.json)') - end - end - - context 'when path versioning is used' do - it "includes a '/'" do - path = Path.new(nil, nil, {}) - allow(path).to receive(:uses_specific_format?) { false } - allow(path).to receive(:uses_path_versioning?) { true } + expect(path.origin).to eql('/foo/hello/namespace/raw_path') + end + end - expect(path.suffix).to eql('(/.:format)') - end + describe '#suffix' do + context 'when using a specific format' do + it 'accepts specified format' do + path = described_class.new(nil, nil, format: 'json', content_types: 'application/json') + expect(path.suffix).to eql('(.json)') end + end - context 'when path versioning is not used' do - it "does not include a '/' when the path has a namespace" do - path = Path.new(nil, 'namespace', {}) - allow(path).to receive(:uses_specific_format?) { false } - allow(path).to receive(:uses_path_versioning?) { true } - - expect(path.suffix).to eql('(.:format)') - end - - it "does not include a '/' when the path has a path" do - path = Path.new('/path', nil, {}) - allow(path).to receive(:uses_specific_format?) { false } - allow(path).to receive(:uses_path_versioning?) { true } - - expect(path.suffix).to eql('(.:format)') - end - - it "includes a '/' otherwise" do - path = Path.new(nil, nil, {}) - allow(path).to receive(:uses_specific_format?) { false } - allow(path).to receive(:uses_path_versioning?) { true } - - expect(path.suffix).to eql('(/.:format)') - end + context 'when path versioning is used' do + it "includes a '/'" do + path = described_class.new(nil, nil, version: :v1, version_options: { using: :path }) + expect(path.suffix).to eql('(/.:format)') end end - describe '#path_with_suffix' do - it 'combines the path and suffix' do - path = Path.new(nil, nil, {}) - allow(path).to receive(:path) { '/the/path' } - allow(path).to receive(:suffix) { 'suffix' } - - expect(path.path_with_suffix).to eql('/the/pathsuffix') + context 'when path versioning is not used' do + it "does not include a '/' when the path has a namespace" do + path = described_class.new(nil, 'namespace', {}) + expect(path.suffix).to eql('(.:format)') end - context 'when using a specific format' do - it 'might have a suffix with specified format' do - path = Path.new(nil, nil, {}) - allow(path).to receive(:path) { '/the/path' } - allow(path).to receive(:uses_specific_format?) { true } - allow(path).to receive(:settings) { { format: :json } } + it "does not include a '/' when the path has a path" do + path = described_class.new('/path', nil, version: :v1, version_options: { using: :path }) + expect(path.suffix).to eql('(.:format)') + end - expect(path.path_with_suffix).to eql('/the/path(.json)') - end + it "includes a '/' otherwise" do + path = described_class.new(nil, nil, version: :v1, version_options: { using: :path }) + expect(path.suffix).to eql('(/.:format)') end end end diff --git a/spec/grape/presenters/presenter_spec.rb b/spec/grape/presenters/presenter_spec.rb index 4e73e8e5b..f673b8fa0 100644 --- a/spec/grape/presenters/presenter_spec.rb +++ b/spec/grape/presenters/presenter_spec.rb @@ -1,70 +1,65 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::Presenters::Presenter do + subject { dummy_class.new } -module Grape - module Presenters - module PresenterSpec - class Dummy - include Grape::DSL::InsideRoute + let(:dummy_class) do + Class.new do + include Grape::DSL::InsideRoute - attr_reader :env, :request, :new_settings + attr_reader :env, :request, :new_settings - def initialize - @env = {} - @header = {} - @new_settings = { namespace_inheritable: {}, namespace_stackable: {} } - end + def initialize + @env = {} + @header = {} + @new_settings = { namespace_inheritable: {}, namespace_stackable: {} } end end + end - describe Presenter do - describe 'represent' do - let(:object_mock) do - Object.new - end + describe 'represent' do + let(:object_mock) do + Object.new + end - it 'represent object' do - expect(Presenter.represent(object_mock)).to eq object_mock - end - end + it 'represent object' do + expect(described_class.represent(object_mock)).to eq object_mock + end + end - subject { PresenterSpec::Dummy.new } + describe 'present' do + let(:hash_mock) do + { key: :value } + end - describe 'present' do - let(:hash_mock) do - { key: :value } - end + describe 'instance' do + before do + subject.present hash_mock, with: described_class + end - describe 'instance' do - before do - subject.present hash_mock, with: Grape::Presenters::Presenter - end - it 'presents dummy hash' do - expect(subject.body).to eq hash_mock - end - end + it 'presents dummy hash' do + expect(subject.body).to eq hash_mock + end + end - describe 'multiple presenter' do - let(:hash_mock1) do - { key1: :value1 } - end + describe 'multiple presenter' do + let(:hash_mock1) do + { key1: :value1 } + end - let(:hash_mock2) do - { key2: :value2 } - end + let(:hash_mock2) do + { key2: :value2 } + end - describe 'instance' do - before do - subject.present hash_mock1, with: Grape::Presenters::Presenter - subject.present hash_mock2, with: Grape::Presenters::Presenter - end + describe 'instance' do + before do + subject.present hash_mock1, with: described_class + subject.present hash_mock2, with: described_class + end - it 'presents both dummy presenter' do - expect(subject.body[:key1]).to eq hash_mock1[:key1] - expect(subject.body[:key2]).to eq hash_mock2[:key2] - end - end + it 'presents both dummy presenter' do + expect(subject.body[:key1]).to eq hash_mock1[:key1] + expect(subject.body[:key2]).to eq hash_mock2[:key2] end end end diff --git a/spec/grape/request_spec.rb b/spec/grape/request_spec.rb index ee5a43643..4e00747cf 100644 --- a/spec/grape/request_spec.rb +++ b/spec/grape/request_spec.rb @@ -1,135 +1,185 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::Request do + let(:default_method) { Rack::GET } + let(:default_params) { {} } + let(:default_options) do + { + method: method, + params: params + } + end + let(:default_env) do + Rack::MockRequest.env_for('/', options) + end + let(:method) { default_method } + let(:params) { default_params } + let(:options) { default_options } + let(:env) { default_env } + + let(:request) do + described_class.new(env) + end -module Grape - describe Request do - let(:default_method) { 'GET' } - let(:default_params) { {} } - let(:default_options) do + describe '#params' do + let(:params) do { - method: method, - params: params + a: '123', + b: 'xyz' } end - let(:default_env) do - Rack::MockRequest.env_for('/', options) + + it 'by default returns stringified parameter keys' do + expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new('a' => '123', 'b' => 'xyz')) end - let(:method) { default_method } - let(:params) { default_params } - let(:options) { default_options } - let(:env) { default_env } - let(:request) do - Grape::Request.new(env) + context 'when build_params_with: Grape::Extensions::Hash::ParamBuilder is specified' do + let(:request) do + described_class.new(env, build_params_with: :hash) + end + + it 'returns symbolized params' do + expect(request.params).to eq(a: '123', b: 'xyz') + end end - describe '#params' do - let(:params) do + describe 'with grape.routing_args' do + let(:options) do + default_options.merge('grape.routing_args' => routing_args) + end + let(:routing_args) do { - a: '123', - b: 'xyz' + version: '123', + route_info: '456', + c: 'ccc' } end - it 'by default returns stringified parameter keys' do - expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new('a' => '123', 'b' => 'xyz')) + it 'cuts version and route_info' do + expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new(a: '123', b: 'xyz', c: 'ccc')) end + end + + context 'when rack_params raises an EOF error' do + before do + allow(request).to receive(:rack_params).and_raise(EOFError) + end + + let(:message) { Grape::Exceptions::EmptyMessageBody.new(nil).to_s } - context 'when build_params_with: Grape::Extensions::Hash::ParamBuilder is specified' do - let(:request) do - Grape::Request.new(env, build_params_with: Grape::Extensions::Hash::ParamBuilder) - end + it 'raises an Grape::Exceptions::EmptyMessageBody' do + expect { request.params }.to raise_error(Grape::Exceptions::EmptyMessageBody, message) + end + end - it 'returns symbolized params' do - expect(request.params).to eq(a: '123', b: 'xyz') - end + context 'when rack_params raises a Rack::Multipart::MultipartPartLimitError' do + before do + allow(request).to receive(:rack_params).and_raise(Rack::Multipart::MultipartPartLimitError) end - describe 'with grape.routing_args' do - let(:options) do - default_options.merge('grape.routing_args' => routing_args) - end - let(:routing_args) do - { - version: '123', - route_info: '456', - c: 'ccc' - } - end + let(:message) { Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit).to_s } - it 'cuts version and route_info' do - expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new(a: '123', b: 'xyz', c: 'ccc')) - end + it 'raises an Rack::Multipart::MultipartPartLimitError' do + expect { request.params }.to raise_error(Grape::Exceptions::TooManyMultipartFiles, message) end end - describe 'when the param_builder is set to Hashie' do + context 'when rack_params raises a Rack::Multipart::MultipartTotalPartLimitError' do before do - Grape.configure do |config| - config.param_builder = Grape::Extensions::Hashie::Mash::ParamBuilder - end + allow(request).to receive(:rack_params).and_raise(Rack::Multipart::MultipartTotalPartLimitError) + end + + let(:message) { Grape::Exceptions::TooManyMultipartFiles.new(Rack::Utils.multipart_part_limit).to_s } + + it 'raises an Rack::Multipart::MultipartPartLimitError' do + expect { request.params }.to raise_error(Grape::Exceptions::TooManyMultipartFiles, message) end + end - after do - Grape.config.reset + context 'when rack_params raises a Rack::QueryParser::ParamsTooDeepError' do + before do + allow(request).to receive(:rack_params).and_raise(Rack::QueryParser::ParamsTooDeepError) end - subject(:request_params) { Grape::Request.new(env, opts).params } + let(:message) { Grape::Exceptions::TooDeepParameters.new(Rack::Utils.param_depth_limit).to_s } - context 'when the API does not include a specific param builder' do - let(:opts) { {} } - it { is_expected.to be_a(Hashie::Mash) } + it 'raises a Grape::Exceptions::TooDeepParameters' do + expect { request.params }.to raise_error(Grape::Exceptions::TooDeepParameters, message) end + end - context 'when the API includes a specific param builder' do - let(:opts) { { build_params_with: Grape::Extensions::Hash::ParamBuilder } } - it { is_expected.to be_a(Hash) } + context 'when rack_params raises a Rack::Utils::ParameterTypeError' do + before do + allow(request).to receive(:rack_params).and_raise(Rack::Utils::ParameterTypeError) + end + + let(:message) { Grape::Exceptions::ConflictingTypes.new.to_s } + + it 'raises a Grape::Exceptions::ConflictingTypes' do + expect { request.params }.to raise_error(Grape::Exceptions::ConflictingTypes, message) end end - describe '#headers' do - let(:options) do - default_options.merge(request_headers) - end - - describe 'with http headers in env' do - let(:request_headers) do - { - 'HTTP_X_GRAPE_IS_COOL' => 'yeah' - } - end - - it 'cuts HTTP_ prefix and capitalizes header name words' do - expect(request.headers).to eq('X-Grape-Is-Cool' => 'yeah') - end - end - - describe 'with non-HTTP_* stuff in env' do - let(:request_headers) do - { - 'HTP_X_GRAPE_ENTITY_TOO' => 'but now we are testing Grape' - } - end - - it 'does not include them' do - expect(request.headers).to eq({}) - end - end - - describe 'with symbolic header names' do - let(:request_headers) do - { - HTTP_GRAPE_LIKES_SYMBOLIC: 'it is true' - } - end - let(:env) do - default_env.merge(request_headers) - end - - it 'converts them to string' do - expect(request.headers).to eq('Grape-Likes-Symbolic' => 'it is true') - end + context 'when rack_params raises a Rack::Utils::InvalidParameterError' do + before do + allow(request).to receive(:rack_params).and_raise(Rack::Utils::InvalidParameterError) + end + + let(:message) { Grape::Exceptions::InvalidParameters.new.to_s } + + it 'raises an Rack::Multipart::MultipartPartLimitError' do + expect { request.params }.to raise_error(Grape::Exceptions::InvalidParameters, message) + end + end + end + + describe '#headers' do + let(:options) do + default_options.merge(request_headers) + end + + describe 'with http headers in env' do + let(:request_headers) do + { + 'HTTP_X_GRAPE_IS_COOL' => 'yeah' + } + end + let(:x_grape_is_cool_header) do + 'x-grape-is-cool' + end + + it 'cuts HTTP_ prefix and capitalizes header name words' do + expect(request.headers).to eq(x_grape_is_cool_header => 'yeah') + end + end + + describe 'with non-HTTP_* stuff in env' do + let(:request_headers) do + { + 'HTP_X_GRAPE_ENTITY_TOO' => 'but now we are testing Grape' + } + end + + it 'does not include them' do + expect(request.headers).to eq({}) + end + end + + describe 'with symbolic header names' do + let(:request_headers) do + { + HTTP_GRAPE_LIKES_SYMBOLIC: 'it is true' + } + end + let(:env) do + default_env.merge(request_headers) + end + let(:grape_likes_symbolic_header) do + 'grape-likes-symbolic' + end + + it 'converts them to string' do + expect(request.headers).to eq(grape_likes_symbolic_header => 'it is true') end end end diff --git a/spec/grape/router/greedy_route_spec.rb b/spec/grape/router/greedy_route_spec.rb new file mode 100644 index 000000000..f93c013b4 --- /dev/null +++ b/spec/grape/router/greedy_route_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +RSpec.describe Grape::Router::GreedyRoute do + let(:instance) { described_class.new(pattern, options) } + let(:index) { 0 } + let(:pattern) { :pattern } + let(:params) do + { a_param: 1 }.freeze + end + let(:options) do + { params: params }.freeze + end + + describe '#pattern' do + subject { instance.pattern } + + it { is_expected.to eq(pattern) } + end + + describe '#options' do + subject { instance.options } + + it { is_expected.to eq(options) } + end + + describe '#params' do + subject { instance.params } + + it { is_expected.to eq(params) } + end + + describe '#attributes' do + subject { instance.attributes } + + it { is_expected.to eq(options) } + end +end diff --git a/spec/grape/router_spec.rb b/spec/grape/router_spec.rb new file mode 100644 index 000000000..afa05d461 --- /dev/null +++ b/spec/grape/router_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +describe Grape::Router do + describe '.normalize_path' do + subject { described_class.normalize_path(path) } + + context 'when no leading slash' do + let(:path) { 'foo%20bar%20baz' } + + it { is_expected.to eq '/foo%20bar%20baz' } + end + + context 'when path ends with slash' do + let(:path) { '/foo%20bar%20baz/' } + + it { is_expected.to eq '/foo%20bar%20baz' } + end + + context 'when path has recurring slashes' do + let(:path) { '////foo%20bar%20baz' } + + it { is_expected.to eq '/foo%20bar%20baz' } + end + + context 'when not greedy' do + let(:path) { '/foo%20bar%20baz' } + + it { is_expected.to eq '/foo%20bar%20baz' } + end + + context 'when encoded string in lowercase' do + let(:path) { '/foo%aabar%aabaz' } + + it { is_expected.to eq '/foo%AAbar%AAbaz' } + end + + context 'when nil' do + let(:path) { nil } + + it { is_expected.to eq '/' } + end + + context 'when empty string' do + let(:path) { '' } + + it { is_expected.to eq '/' } + end + + context 'when encoding is different' do + subject { described_class.normalize_path(path).encoding } + + let(:path) { '/foo%AAbar%AAbaz'.b } + + it { is_expected.to eq(Encoding::BINARY) } + end + end +end diff --git a/spec/grape/util/inheritable_setting_spec.rb b/spec/grape/util/inheritable_setting_spec.rb index 0e0b672e7..03a79368c 100644 --- a/spec/grape/util/inheritable_setting_spec.rb +++ b/spec/grape/util/inheritable_setting_spec.rb @@ -1,243 +1,236 @@ # frozen_string_literal: true -require 'spec_helper' -module Grape - module Util - describe InheritableSetting do - before :each do - InheritableSetting.reset_global! - end - - let(:parent) do - Grape::Util::InheritableSetting.new.tap do |settings| - settings.global[:global_thing] = :global_foo_bar - settings.namespace[:namespace_thing] = :namespace_foo_bar - settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar - settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar - settings.route[:route_thing] = :route_foo_bar - end - end - - let(:other_parent) do - Grape::Util::InheritableSetting.new.tap do |settings| - settings.namespace[:namespace_thing] = :namespace_foo_bar_other - settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other - settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other - settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar_other - settings.route[:route_thing] = :route_foo_bar_other - end - end - - before :each do - subject.inherit_from parent - end - - describe '#global' do - it 'sets a global value' do - subject.global[:some_thing] = :foo_bar - expect(subject.global[:some_thing]).to eq :foo_bar - subject.global[:some_thing] = :foo_bar_next - expect(subject.global[:some_thing]).to eq :foo_bar_next - end - - it 'sets the global inherited values' do - expect(subject.global[:global_thing]).to eq :global_foo_bar - end - - it 'overrides global values' do - subject.global[:global_thing] = :global_new_foo_bar - expect(parent.global[:global_thing]).to eq :global_new_foo_bar - end - - it 'should handle different parents' do - subject.global[:global_thing] = :global_new_foo_bar - - subject.inherit_from other_parent - - expect(parent.global[:global_thing]).to eq :global_new_foo_bar - expect(other_parent.global[:global_thing]).to eq :global_new_foo_bar - end - end - - describe '#api_class' do - it 'is specific to the class' do - subject.api_class[:some_thing] = :foo_bar - parent.api_class[:some_thing] = :some_thing - - expect(subject.api_class[:some_thing]).to eq :foo_bar - expect(parent.api_class[:some_thing]).to eq :some_thing - end - end - - describe '#namespace' do - it 'sets a value until the end of a namespace' do - subject.namespace[:some_thing] = :foo_bar - expect(subject.namespace[:some_thing]).to eq :foo_bar - end - - it 'uses new values when a new namespace starts' do - subject.namespace[:namespace_thing] = :new_namespace_foo_bar - expect(subject.namespace[:namespace_thing]).to eq :new_namespace_foo_bar - - expect(parent.namespace[:namespace_thing]).to eq :namespace_foo_bar - end - end - - describe '#namespace_inheritable' do - it 'works with inheritable values' do - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - end - - it 'should handle different parents' do - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - - subject.inherit_from other_parent +describe Grape::Util::InheritableSetting do + before do + described_class.reset_global! + subject.inherit_from parent + end + + let(:parent) do + described_class.new.tap do |settings| + settings.global[:global_thing] = :global_foo_bar + settings.namespace[:namespace_thing] = :namespace_foo_bar + settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar + settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar + settings.route[:route_thing] = :route_foo_bar + end + end - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar_other - - subject.inherit_from parent + let(:other_parent) do + described_class.new.tap do |settings| + settings.namespace[:namespace_thing] = :namespace_foo_bar_other + settings.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar_other + settings.namespace_stackable[:namespace_stackable_thing] = :namespace_stackable_foo_bar_other + settings.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :namespace_reverse_stackable_foo_bar_other + settings.route[:route_thing] = :route_foo_bar_other + end + end - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - - subject.inherit_from other_parent + describe '#global' do + it 'sets a global value' do + subject.global[:some_thing] = :foo_bar + expect(subject.global[:some_thing]).to eq :foo_bar + subject.global[:some_thing] = :foo_bar_next + expect(subject.global[:some_thing]).to eq :foo_bar_next + end - subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing + it 'sets the global inherited values' do + expect(subject.global[:global_thing]).to eq :global_foo_bar + end + + it 'overrides global values' do + subject.global[:global_thing] = :global_new_foo_bar + expect(parent.global[:global_thing]).to eq :global_new_foo_bar + end + + it 'handles different parents' do + subject.global[:global_thing] = :global_new_foo_bar + + subject.inherit_from other_parent + + expect(parent.global[:global_thing]).to eq :global_new_foo_bar + expect(other_parent.global[:global_thing]).to eq :global_new_foo_bar + end + end - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + describe '#api_class' do + it 'is specific to the class' do + subject.api_class[:some_thing] = :foo_bar + parent.api_class[:some_thing] = :some_thing - subject.inherit_from parent + expect(subject.api_class[:some_thing]).to eq :foo_bar + expect(parent.api_class[:some_thing]).to eq :some_thing + end + end - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing - end - end + describe '#namespace' do + it 'sets a value until the end of a namespace' do + subject.namespace[:some_thing] = :foo_bar + expect(subject.namespace[:some_thing]).to eq :foo_bar + end - describe '#namespace_stackable' do - it 'works with stackable values' do - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + it 'uses new values when a new namespace starts' do + subject.namespace[:namespace_thing] = :new_namespace_foo_bar + expect(subject.namespace[:namespace_thing]).to eq :new_namespace_foo_bar - subject.inherit_from other_parent + expect(parent.namespace[:namespace_thing]).to eq :namespace_foo_bar + end + end - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] - end - end + describe '#namespace_inheritable' do + it 'works with inheritable values' do + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + end - describe '#namespace_reverse_stackable' do - it 'works with reverse stackable values' do - expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] + it 'handles different parents' do + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - subject.inherit_from other_parent + subject.inherit_from other_parent - expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar_other] - end - end + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar_other - describe '#route' do - it 'sets a value until the next route' do - subject.route[:some_thing] = :foo_bar - expect(subject.route[:some_thing]).to eq :foo_bar + subject.inherit_from parent - subject.route_end + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar - expect(subject.route[:some_thing]).to be_nil - end + subject.inherit_from other_parent - it 'works with route values' do - expect(subject.route[:route_thing]).to eq :route_foo_bar - end - end + subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing - describe '#api_class' do - it 'is specific to the class' do - subject.api_class[:some_thing] = :foo_bar - expect(subject.api_class[:some_thing]).to eq :foo_bar - end - end + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing - describe '#inherit_from' do - it 'notifies clones' do - new_settings = subject.point_in_time_copy - expect(new_settings).to receive(:inherit_from).with(other_parent) + subject.inherit_from parent - subject.inherit_from other_parent - end - end + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + end + end - describe '#point_in_time_copy' do - let!(:cloned_obj) { subject.point_in_time_copy } + describe '#namespace_stackable' do + it 'works with stackable values' do + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] - it 'resets point_in_time_copies' do - expect(cloned_obj.point_in_time_copies).to be_empty - end + subject.inherit_from other_parent - it 'decouples namespace values' do - subject.namespace[:namespace_thing] = :namespace_foo_bar + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar_other] + end + end - cloned_obj.namespace[:namespace_thing] = :new_namespace_foo_bar - expect(subject.namespace[:namespace_thing]).to eq :namespace_foo_bar - end + describe '#namespace_reverse_stackable' do + it 'works with reverse stackable values' do + expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] - it 'decouples namespace inheritable values' do - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + subject.inherit_from other_parent - subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar_other] + end + end - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + describe '#route' do + it 'sets a value until the next route' do + subject.route[:some_thing] = :foo_bar + expect(subject.route[:some_thing]).to eq :foo_bar - cloned_obj.namespace_inheritable[:namespace_inheritable_thing] = :my_cloned_thing - expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_cloned_thing - expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing - end + subject.route_end - it 'decouples namespace stackable values' do - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + expect(subject.route[:some_thing]).to be_nil + end - subject.namespace_stackable[:namespace_stackable_thing] = :other_thing - expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] - expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] - end + it 'works with route values' do + expect(subject.route[:route_thing]).to eq :route_foo_bar + end + end - it 'decouples namespace reverse stackable values' do - expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] + describe '#api_class' do + it 'is specific to the class' do + subject.api_class[:some_thing] = :foo_bar + expect(subject.api_class[:some_thing]).to eq :foo_bar + end + end + + describe '#inherit_from' do + it 'notifies clones' do + new_settings = subject.point_in_time_copy + expect(new_settings).to receive(:inherit_from).with(other_parent) + + subject.inherit_from other_parent + end + end + + describe '#point_in_time_copy' do + let!(:cloned_obj) { subject.point_in_time_copy } + + it 'resets point_in_time_copies' do + expect(cloned_obj.point_in_time_copies).to be_empty + end + + it 'decouples namespace values' do + subject.namespace[:namespace_thing] = :namespace_foo_bar + + cloned_obj.namespace[:namespace_thing] = :new_namespace_foo_bar + expect(subject.namespace[:namespace_thing]).to eq :namespace_foo_bar + end + + it 'decouples namespace inheritable values' do + expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + + subject.namespace_inheritable[:namespace_inheritable_thing] = :my_thing + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + + expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :namespace_inheritable_foo_bar + + cloned_obj.namespace_inheritable[:namespace_inheritable_thing] = :my_cloned_thing + expect(cloned_obj.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_cloned_thing + expect(subject.namespace_inheritable[:namespace_inheritable_thing]).to eq :my_thing + end + + it 'decouples namespace stackable values' do + expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + + subject.namespace_stackable[:namespace_stackable_thing] = :other_thing + expect(subject.namespace_stackable[:namespace_stackable_thing]).to eq %i[namespace_stackable_foo_bar other_thing] + expect(cloned_obj.namespace_stackable[:namespace_stackable_thing]).to eq [:namespace_stackable_foo_bar] + end + + it 'decouples namespace reverse stackable values' do + expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] + + subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :other_thing + expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq %i[other_thing namespace_reverse_stackable_foo_bar] + expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] + end + + it 'decouples route values' do + expect(cloned_obj.route[:route_thing]).to eq :route_foo_bar + + subject.route[:route_thing] = :new_route_foo_bar + expect(cloned_obj.route[:route_thing]).to eq :route_foo_bar + end + + it 'adds itself to original as clone' do + expect(subject.point_in_time_copies).to include(cloned_obj) + end + end - subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = :other_thing - expect(subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq %i[other_thing namespace_reverse_stackable_foo_bar] - expect(cloned_obj.namespace_reverse_stackable[:namespace_reverse_stackable_thing]).to eq [:namespace_reverse_stackable_foo_bar] - end - - it 'decouples route values' do - expect(cloned_obj.route[:route_thing]).to eq :route_foo_bar - - subject.route[:route_thing] = :new_route_foo_bar - expect(cloned_obj.route[:route_thing]).to eq :route_foo_bar - end - - it 'adds itself to original as clone' do - expect(subject.point_in_time_copies).to include(cloned_obj) - end - end - - describe '#to_hash' do - it 'return all settings as a hash' do - subject.global[:global_thing] = :global_foo_bar - subject.namespace[:namespace_thing] = :namespace_foo_bar - subject.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar - subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] - subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = [:namespace_reverse_stackable_foo_bar] - subject.route[:route_thing] = :route_foo_bar - - expect(subject.to_hash).to include(global: { global_thing: :global_foo_bar }) - expect(subject.to_hash).to include(namespace: { namespace_thing: :namespace_foo_bar }) - expect(subject.to_hash).to include(namespace_inheritable: { - namespace_inheritable_thing: :namespace_inheritable_foo_bar - }) - expect(subject.to_hash).to include(namespace_stackable: { namespace_stackable_thing: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] }) - expect(subject.to_hash).to include(namespace_reverse_stackable: - { namespace_reverse_stackable_thing: [[:namespace_reverse_stackable_foo_bar], :namespace_reverse_stackable_foo_bar] }) - expect(subject.to_hash).to include(route: { route_thing: :route_foo_bar }) - end - end + describe '#to_hash' do + it 'return all settings as a hash' do + subject.global[:global_thing] = :global_foo_bar + subject.namespace[:namespace_thing] = :namespace_foo_bar + subject.namespace_inheritable[:namespace_inheritable_thing] = :namespace_inheritable_foo_bar + subject.namespace_stackable[:namespace_stackable_thing] = [:namespace_stackable_foo_bar] + subject.namespace_reverse_stackable[:namespace_reverse_stackable_thing] = [:namespace_reverse_stackable_foo_bar] + subject.route[:route_thing] = :route_foo_bar + expect(subject.to_hash).to match( + global: { global_thing: :global_foo_bar }, + namespace: { namespace_thing: :namespace_foo_bar }, + namespace_inheritable: { + namespace_inheritable_thing: :namespace_inheritable_foo_bar + }, + namespace_stackable: { namespace_stackable_thing: [:namespace_stackable_foo_bar, [:namespace_stackable_foo_bar]] }, + namespace_reverse_stackable: + { namespace_reverse_stackable_thing: [[:namespace_reverse_stackable_foo_bar], :namespace_reverse_stackable_foo_bar] }, + route: { route_thing: :route_foo_bar } + ) end end end diff --git a/spec/grape/util/inheritable_values_spec.rb b/spec/grape/util/inheritable_values_spec.rb index a5003d875..1f424e59f 100644 --- a/spec/grape/util/inheritable_values_spec.rb +++ b/spec/grape/util/inheritable_values_spec.rb @@ -1,78 +1,74 @@ # frozen_string_literal: true -require 'spec_helper' -module Grape - module Util - describe InheritableValues do - let(:parent) { InheritableValues.new } - subject { InheritableValues.new(parent) } +describe Grape::Util::InheritableValues do + subject { described_class.new(parent) } - describe '#delete' do - it 'deletes a key' do - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to be_nil - end + let(:parent) { described_class.new } - it 'does not delete parent values' do - parent[:some_thing] = :foo - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to eq :foo - end - end + describe '#delete' do + it 'deletes a key' do + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to be_nil + end - describe '#[]' do - it 'returns a value' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq :foo - end + it 'does not delete parent values' do + parent[:some_thing] = :foo + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to eq :foo + end + end - it 'returns parent value when no value is set' do - parent[:some_thing] = :foo - expect(subject[:some_thing]).to eq :foo - end + describe '#[]' do + it 'returns a value' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq :foo + end - it 'overwrites parent value with the current one' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(subject[:some_thing]).to eq :foo_bar - end + it 'returns parent value when no value is set' do + parent[:some_thing] = :foo + expect(subject[:some_thing]).to eq :foo + end - it 'parent values are not changed' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(parent[:some_thing]).to eq :foo - end - end + it 'overwrites parent value with the current one' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(subject[:some_thing]).to eq :foo_bar + end - describe '#[]=' do - it 'sets a value' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq :foo - end - end + it 'parent values are not changed' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(parent[:some_thing]).to eq :foo + end + end - describe '#to_hash' do - it 'returns a Hash representation' do - parent[:some_thing] = :foo - subject[:some_thing_more] = :foo_bar - expect(subject.to_hash).to eq(some_thing: :foo, some_thing_more: :foo_bar) - end - end + describe '#[]=' do + it 'sets a value' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq :foo + end + end + + describe '#to_hash' do + it 'returns a Hash representation' do + parent[:some_thing] = :foo + subject[:some_thing_more] = :foo_bar + expect(subject.to_hash).to eq(some_thing: :foo, some_thing_more: :foo_bar) + end + end - describe '#clone' do - let(:obj_cloned) { subject.clone } + describe '#clone' do + let(:obj_cloned) { subject.clone } - context 'complex (i.e. not primitive) data types (ex. entity classes, please see bug #891)' do - let(:description) { { entity: double } } + context 'complex (i.e. not primitive) data types (ex. entity classes, please see bug #891)' do + let(:description) { { entity: double } } - before { subject[:description] = description } + before { subject[:description] = description } - it 'copies values; does not duplicate them' do - expect(obj_cloned[:description]).to eq description - end - end + it 'copies values; does not duplicate them' do + expect(obj_cloned[:description]).to eq description end end end diff --git a/spec/grape/util/media_type_spec.rb b/spec/grape/util/media_type_spec.rb new file mode 100644 index 000000000..2905c3f9e --- /dev/null +++ b/spec/grape/util/media_type_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +RSpec.describe Grape::Util::MediaType do + shared_examples 'MediaType' do + it { is_expected.to eq(described_class.new(type: type, subtype: subtype)) } + end + + describe '.parse' do + subject(:media_type) { described_class.parse(header) } + + context 'when header blank?' do + let(:header) { nil } + + it { is_expected.to be_nil } + end + + context 'when header is not a mime type' do + let(:header) { 'abc' } + + it { is_expected.to be_nil } + end + + context 'when header is a valid mime type' do + let(:header) { [type, subtype].join('/') } + let(:type) { 'text' } + let(:subtype) { 'html' } + + it_behaves_like 'MediaType' + + context 'when header is a vendor mime type' do + let(:type) { 'application' } + let(:subtype) { 'vnd.test-v1+json' } + + it_behaves_like 'MediaType' + end + + context 'when header is a vendor mime type without version' do + let(:type) { 'application' } + let(:subtype) { 'vnd.ms-word' } + + it_behaves_like 'MediaType' + end + end + end + + describe '.match?' do + subject { described_class.match?(media_type) } + + context 'when media_type is blank?' do + let(:media_type) { nil } + + it { is_expected.to be_falsey } + end + + context 'when header is not a mime type' do + let(:media_type) { 'abc' } + + it { is_expected.to be_falsey } + end + + context 'when header is a valid mime type but not vendor' do + let(:media_type) { 'text/html' } + + it { is_expected.to be_falsey } + end + + context 'when header is a vendor mime type' do + let(:media_type) { 'application/vnd.test-v1+json' } + + it { is_expected.to be_truthy } + end + end + + describe '.best_quality' do + subject(:media_type) { described_class.best_quality(header, available_media_types) } + + let(:available_media_types) { %w[application/json text/html] } + + context 'when header is blank?' do + let(:header) { nil } + let(:type) { 'application' } + let(:subtype) { 'json' } + + it_behaves_like 'MediaType' + end + + context 'when header is not blank' do + let(:header) { [type, subtype].join('/') } + let(:type) { 'text' } + let(:subtype) { 'html' } + + it 'calls Rack::Utils.best_q_match' do + allow(Rack::Utils).to receive(:best_q_match).and_call_original + expect(media_type).to eq(described_class.new(type: type, subtype: subtype)) + end + end + end + + describe '.==' do + subject { described_class.new(type: type, subtype: subtype) } + + let(:type) { 'application' } + let(:subtype) { 'vnd.test-v1+json' } + let(:other_media_type_class) { Class.new(Struct.new(:type, :subtype, :vendor, :version, :format)) } + let(:other_media_type_instance) { other_media_type_class.new(type, subtype, 'test', 'v1', 'json') } + + it { is_expected.not_to eq(other_media_type_class.new(type, subtype, 'test', 'v1', 'json')) } + end + + describe '.hash' do + subject { Set.new([described_class.new(type: type, subtype: subtype)]) } + + let(:type) { 'text' } + let(:subtype) { 'html' } + + it { is_expected.to include(described_class.new(type: type, subtype: subtype)) } + end +end diff --git a/spec/grape/util/reverse_stackable_values_spec.rb b/spec/grape/util/reverse_stackable_values_spec.rb index c5ffbd293..4037f5778 100644 --- a/spec/grape/util/reverse_stackable_values_spec.rb +++ b/spec/grape/util/reverse_stackable_values_spec.rb @@ -1,132 +1,129 @@ # frozen_string_literal: true -require 'spec_helper' -module Grape - module Util - describe ReverseStackableValues do - let(:parent) { described_class.new } - subject { described_class.new(parent) } - - describe '#keys' do - it 'returns all keys' do - subject[:some_thing] = :foo_bar - subject[:some_thing_else] = :foo_bar - expect(subject.keys).to eq %i[some_thing some_thing_else].sort - end - - it 'returns merged keys with parent' do - parent[:some_thing] = :foo - parent[:some_thing_else] = :foo - - subject[:some_thing] = :foo_bar - subject[:some_thing_more] = :foo_bar - - expect(subject.keys).to eq %i[some_thing some_thing_else some_thing_more].sort - end - end +describe Grape::Util::ReverseStackableValues do + subject { described_class.new(parent) } - describe '#delete' do - it 'deletes a key' do - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to eq [] - end - - it 'does not delete parent values' do - parent[:some_thing] = :foo - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to eq [:foo] - end - end + let(:parent) { described_class.new } - describe '#[]' do - it 'returns an array of values' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end - - it 'returns parent value when no value is set' do - parent[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end - - it 'combines parent and actual values (actual first)' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(subject[:some_thing]).to eq %i[foo_bar foo] - end - - it 'parent values are not changed' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(parent[:some_thing]).to eq [:foo] - end - end + describe '#keys' do + it 'returns all keys' do + subject[:some_thing] = :foo_bar + subject[:some_thing_else] = :foo_bar + expect(subject.keys).to eq %i[some_thing some_thing_else].sort + end - describe '#[]=' do - it 'sets a value' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end + it 'returns merged keys with parent' do + parent[:some_thing] = :foo + parent[:some_thing_else] = :foo - it 'pushes further values' do - subject[:some_thing] = :foo - subject[:some_thing] = :bar - expect(subject[:some_thing]).to eq %i[foo bar] - end + subject[:some_thing] = :foo_bar + subject[:some_thing_more] = :foo_bar - it 'can handle array values' do - subject[:some_thing] = :foo - subject[:some_thing] = %i[bar more] - expect(subject[:some_thing]).to eq [:foo, %i[bar more]] + expect(subject.keys).to eq %i[some_thing some_thing_else some_thing_more].sort + end + end - parent[:some_thing_else] = %i[foo bar] - subject[:some_thing_else] = %i[some bar foo] + describe '#delete' do + it 'deletes a key' do + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to eq [] + end - expect(subject[:some_thing_else]).to eq [%i[some bar foo], %i[foo bar]] - end - end + it 'does not delete parent values' do + parent[:some_thing] = :foo + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to eq [:foo] + end + end - describe '#to_hash' do - it 'returns a Hash representation' do - parent[:some_thing] = :foo - subject[:some_thing] = %i[bar more] - subject[:some_thing_more] = :foo_bar - expect(subject.to_hash).to eq( - some_thing: [%i[bar more], :foo], - some_thing_more: [:foo_bar] - ) - end - end + describe '#[]' do + it 'returns an array of values' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end + + it 'returns parent value when no value is set' do + parent[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end + + it 'combines parent and actual values (actual first)' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(subject[:some_thing]).to eq %i[foo_bar foo] + end + + it 'parent values are not changed' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(parent[:some_thing]).to eq [:foo] + end + end + + describe '#[]=' do + it 'sets a value' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end + + it 'pushes further values' do + subject[:some_thing] = :foo + subject[:some_thing] = :bar + expect(subject[:some_thing]).to eq %i[foo bar] + end + + it 'can handle array values' do + subject[:some_thing] = :foo + subject[:some_thing] = %i[bar more] + expect(subject[:some_thing]).to eq [:foo, %i[bar more]] + + parent[:some_thing_else] = %i[foo bar] + subject[:some_thing_else] = %i[some bar foo] + + expect(subject[:some_thing_else]).to eq [%i[some bar foo], %i[foo bar]] + end + end + + describe '#to_hash' do + it 'returns a Hash representation' do + parent[:some_thing] = :foo + subject[:some_thing] = %i[bar more] + subject[:some_thing_more] = :foo_bar + expect(subject.to_hash).to eq( + some_thing: [%i[bar more], :foo], + some_thing_more: [:foo_bar] + ) + end + end + + describe '#clone' do + let(:obj_cloned) { subject.clone } + + it 'copies all values' do + parent = described_class.new + child = described_class.new parent + grandchild = described_class.new child + + parent[:some_thing] = :foo + child[:some_thing] = %i[bar more] + grandchild[:some_thing] = :grand_foo_bar + grandchild[:some_thing_more] = :foo_bar + + expect(grandchild.clone.to_hash).to eq( + some_thing: [:grand_foo_bar, %i[bar more], :foo], + some_thing_more: [:foo_bar] + ) + end + + context 'complex (i.e. not primitive) data types (ex. middleware, please see bug #930)' do + let(:middleware) { double } + + before { subject[:middleware] = middleware } - describe '#clone' do - let(:obj_cloned) { subject.clone } - it 'copies all values' do - parent = described_class.new - child = described_class.new parent - grandchild = described_class.new child - - parent[:some_thing] = :foo - child[:some_thing] = %i[bar more] - grandchild[:some_thing] = :grand_foo_bar - grandchild[:some_thing_more] = :foo_bar - - expect(grandchild.clone.to_hash).to eq( - some_thing: [:grand_foo_bar, %i[bar more], :foo], - some_thing_more: [:foo_bar] - ) - end - - context 'complex (i.e. not primitive) data types (ex. middleware, please see bug #930)' do - let(:middleware) { double } - - before { subject[:middleware] = middleware } - - it 'copies values; does not duplicate them' do - expect(obj_cloned[:middleware]).to eq [middleware] - end - end + it 'copies values; does not duplicate them' do + expect(obj_cloned[:middleware]).to eq [middleware] end end end diff --git a/spec/grape/util/stackable_values_spec.rb b/spec/grape/util/stackable_values_spec.rb index c7defdf75..0bcd9c3d9 100644 --- a/spec/grape/util/stackable_values_spec.rb +++ b/spec/grape/util/stackable_values_spec.rb @@ -1,126 +1,123 @@ # frozen_string_literal: true -require 'spec_helper' -module Grape - module Util - describe StackableValues do - let(:parent) { StackableValues.new } - subject { StackableValues.new(parent) } - - describe '#keys' do - it 'returns all keys' do - subject[:some_thing] = :foo_bar - subject[:some_thing_else] = :foo_bar - expect(subject.keys).to eq %i[some_thing some_thing_else].sort - end - - it 'returns merged keys with parent' do - parent[:some_thing] = :foo - parent[:some_thing_else] = :foo - - subject[:some_thing] = :foo_bar - subject[:some_thing_more] = :foo_bar - - expect(subject.keys).to eq %i[some_thing some_thing_else some_thing_more].sort - end - end +describe Grape::Util::StackableValues do + subject { described_class.new(parent) } - describe '#delete' do - it 'deletes a key' do - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to eq [] - end - - it 'does not delete parent values' do - parent[:some_thing] = :foo - subject[:some_thing] = :new_foo_bar - subject.delete :some_thing - expect(subject[:some_thing]).to eq [:foo] - end - end + let(:parent) { described_class.new } - describe '#[]' do - it 'returns an array of values' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end - - it 'returns parent value when no value is set' do - parent[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end - - it 'combines parent and actual values' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(subject[:some_thing]).to eq %i[foo foo_bar] - end - - it 'parent values are not changed' do - parent[:some_thing] = :foo - subject[:some_thing] = :foo_bar - expect(parent[:some_thing]).to eq [:foo] - end - end + describe '#keys' do + it 'returns all keys' do + subject[:some_thing] = :foo_bar + subject[:some_thing_else] = :foo_bar + expect(subject.keys).to eq %i[some_thing some_thing_else].sort + end - describe '#[]=' do - it 'sets a value' do - subject[:some_thing] = :foo - expect(subject[:some_thing]).to eq [:foo] - end + it 'returns merged keys with parent' do + parent[:some_thing] = :foo + parent[:some_thing_else] = :foo - it 'pushes further values' do - subject[:some_thing] = :foo - subject[:some_thing] = :bar - expect(subject[:some_thing]).to eq %i[foo bar] - end + subject[:some_thing] = :foo_bar + subject[:some_thing_more] = :foo_bar - it 'can handle array values' do - subject[:some_thing] = :foo - subject[:some_thing] = %i[bar more] - expect(subject[:some_thing]).to eq [:foo, %i[bar more]] + expect(subject.keys).to eq %i[some_thing some_thing_else some_thing_more].sort + end + end - parent[:some_thing_else] = %i[foo bar] - subject[:some_thing_else] = %i[some bar foo] + describe '#delete' do + it 'deletes a key' do + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to eq [] + end - expect(subject[:some_thing_else]).to eq [%i[foo bar], %i[some bar foo]] - end - end + it 'does not delete parent values' do + parent[:some_thing] = :foo + subject[:some_thing] = :new_foo_bar + subject.delete :some_thing + expect(subject[:some_thing]).to eq [:foo] + end + end - describe '#to_hash' do - it 'returns a Hash representation' do - parent[:some_thing] = :foo - subject[:some_thing] = %i[bar more] - subject[:some_thing_more] = :foo_bar - expect(subject.to_hash).to eq(some_thing: [:foo, %i[bar more]], some_thing_more: [:foo_bar]) - end - end + describe '#[]' do + it 'returns an array of values' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end + + it 'returns parent value when no value is set' do + parent[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end - describe '#clone' do - let(:obj_cloned) { subject.clone } - it 'copies all values' do - parent = StackableValues.new - child = StackableValues.new parent - grandchild = StackableValues.new child + it 'combines parent and actual values' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(subject[:some_thing]).to eq %i[foo foo_bar] + end - parent[:some_thing] = :foo - child[:some_thing] = %i[bar more] - grandchild[:some_thing] = :grand_foo_bar - grandchild[:some_thing_more] = :foo_bar + it 'parent values are not changed' do + parent[:some_thing] = :foo + subject[:some_thing] = :foo_bar + expect(parent[:some_thing]).to eq [:foo] + end + end - expect(grandchild.clone.to_hash).to eq(some_thing: [:foo, %i[bar more], :grand_foo_bar], some_thing_more: [:foo_bar]) - end + describe '#[]=' do + it 'sets a value' do + subject[:some_thing] = :foo + expect(subject[:some_thing]).to eq [:foo] + end + + it 'pushes further values' do + subject[:some_thing] = :foo + subject[:some_thing] = :bar + expect(subject[:some_thing]).to eq %i[foo bar] + end + + it 'can handle array values' do + subject[:some_thing] = :foo + subject[:some_thing] = %i[bar more] + expect(subject[:some_thing]).to eq [:foo, %i[bar more]] + + parent[:some_thing_else] = %i[foo bar] + subject[:some_thing_else] = %i[some bar foo] + + expect(subject[:some_thing_else]).to eq [%i[foo bar], %i[some bar foo]] + end + end + + describe '#to_hash' do + it 'returns a Hash representation' do + parent[:some_thing] = :foo + subject[:some_thing] = %i[bar more] + subject[:some_thing_more] = :foo_bar + expect(subject.to_hash).to eq(some_thing: [:foo, %i[bar more]], some_thing_more: [:foo_bar]) + end + end + + describe '#clone' do + let(:obj_cloned) { subject.clone } + + it 'copies all values' do + parent = described_class.new + child = described_class.new parent + grandchild = described_class.new child + + parent[:some_thing] = :foo + child[:some_thing] = %i[bar more] + grandchild[:some_thing] = :grand_foo_bar + grandchild[:some_thing_more] = :foo_bar + + expect(grandchild.clone.to_hash).to eq(some_thing: [:foo, %i[bar more], :grand_foo_bar], some_thing_more: [:foo_bar]) + end - context 'complex (i.e. not primitive) data types (ex. middleware, please see bug #930)' do - let(:middleware) { double } + context 'complex (i.e. not primitive) data types (ex. middleware, please see bug #930)' do + let(:middleware) { double } - before { subject[:middleware] = middleware } + before { subject[:middleware] = middleware } - it 'copies values; does not duplicate them' do - expect(obj_cloned[:middleware]).to eq [middleware] - end - end + it 'copies values; does not duplicate them' do + expect(obj_cloned[:middleware]).to eq [middleware] end end end diff --git a/spec/grape/util/strict_hash_configuration_spec.rb b/spec/grape/util/strict_hash_configuration_spec.rb index c55fd616b..300986860 100644 --- a/spec/grape/util/strict_hash_configuration_spec.rb +++ b/spec/grape/util/strict_hash_configuration_spec.rb @@ -1,39 +1,34 @@ # frozen_string_literal: true -require 'spec_helper' -module Grape - module Util - describe 'StrictHashConfiguration' do - subject do - Class.new do - include Grape::Util::StrictHashConfiguration.module(:config1, :config2, config3: [:config4], config5: [config6: %i[config7 config8]]) - end - end +describe Grape::Util::StrictHashConfiguration do + subject do + Class.new do + include Grape::Util::StrictHashConfiguration.module(:config1, :config2, config3: [:config4], config5: [config6: %i[config7 config8]]) + end + end - it 'set nested configs' do - subject.configure do - config1 'alpha' - config2 'beta' + it 'set nested configs' do + subject.configure do + config1 'alpha' + config2 'beta' - config3 do - config4 'gamma' - end + config3 do + config4 'gamma' + end - local_var = 8 + local_var = 8 - config5 do - config6 do - config7 7 - config8 local_var - end - end + config5 do + config6 do + config7 7 + config8 local_var end - - expect(subject.settings).to eq(config1: 'alpha', - config2: 'beta', - config3: { config4: 'gamma' }, - config5: { config6: { config7: 7, config8: 8 } }) end end + + expect(subject.settings).to eq(config1: 'alpha', + config2: 'beta', + config3: { config4: 'gamma' }, + config5: { config6: { config7: 7, config8: 8 } }) end end diff --git a/spec/grape/validations/attributes_doc_spec.rb b/spec/grape/validations/attributes_doc_spec.rb new file mode 100644 index 000000000..d5ae81d2f --- /dev/null +++ b/spec/grape/validations/attributes_doc_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +describe Grape::Validations::AttributesDoc do + shared_examples 'an optional doc attribute' do |attr| + it 'does not mention it' do + expected_opts.delete(attr) + validations.delete(attr) + + expect(subject.first['nested[engine_age]']).not_to have_key(attr) + end + end + + let(:api) { Class.new(Grape::API::Instance) } + let(:scope) do + params = nil + api_instance = api + + # just to get nested params + Grape::Validations::ParamsScope.new(type: Hash, api: api) do + params = Grape::Validations::ParamsScope.new(element: 'nested', + type: Hash, + api: api_instance, + parent: self) + end + + params + end + + let(:validations) do + { + presence: true, + desc: 'Age of...', + documentation: 'Age is...', + default: 1, + length: { min: 1, max: 13 } + } + end + + let(:doc) { described_class.new(api, scope) } + + describe '#extract_details' do + subject { doc.extract_details(validations) } + + it 'cleans up doc attrs needed for documentation only' do + subject + + expect(validations[:desc]).to be_nil + expect(validations[:documentation]).to be_nil + end + + it 'does not clean up doc attrs mandatory for validators' do + subject + + expect(validations[:presence]).not_to be_nil + expect(validations[:default]).not_to be_nil + end + + it 'tells when attributes are required' do + subject + + expect(doc.required).to be_truthy + end + end + + describe '#document' do + subject do + doc.extract_details validations + doc.document attrs + end + + let(:attrs) { %w[engine_age car_age] } + let(:valid_values) { [1, 3, 5, 8] } + + let!(:expected_opts) do + { + required: true, + desc: validations[:desc], + documentation: validations[:documentation], + default: validations[:default], + type: 'Integer', + values: valid_values, + min_length: validations[:length][:min], + max_length: validations[:length][:max] + } + end + + before do + doc.type = Integer + doc.values = valid_values + end + + context 'documentation is enabled' do + subject do + super() + api.namespace_stackable(:params) + end + + it 'documents attributes' do + expect(subject.first).to eq('nested[engine_age]' => expected_opts, + 'nested[car_age]' => expected_opts) + end + + it_behaves_like 'an optional doc attribute', :default + it_behaves_like 'an optional doc attribute', :documentation + it_behaves_like 'an optional doc attribute', :desc + it_behaves_like 'an optional doc attribute', :type do + before { doc.type = nil } + end + it_behaves_like 'an optional doc attribute', :values do + before { doc.values = nil } + end + + context 'false as a default value' do + before { validations[:default] = false } + + it 'is still documented' do + doc = subject.first['nested[engine_age]'] + + expect(doc).to have_key(:default) + expect(doc[:default]).to be(false) + end + end + + context 'nil as a default value' do + before { validations[:default] = nil } + + it 'is still documented' do + doc = subject.first['nested[engine_age]'] + + expect(doc).to have_key(:default) + expect(doc[:default]).to be_nil + end + end + + context 'the description key instead of desc' do + let!(:desc) { validations.delete(:desc) } + + before { validations[:description] = desc } + + it 'adds the given description' do + expect(subject.first['nested[engine_age]'][:desc]).to eq(desc) + end + end + end + + context 'documentation is disabled' do + before { api.namespace_inheritable :do_not_document, true } + + it 'does not document attributes' do + subject + + expect(api.namespace_stackable(:params)).to eq([]) + end + end + end +end diff --git a/spec/grape/validations/attributes_iterator_spec.rb b/spec/grape/validations/attributes_iterator_spec.rb deleted file mode 100644 index 594c8ca19..000000000 --- a/spec/grape/validations/attributes_iterator_spec.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Grape::Validations::AttributesIterator do -end diff --git a/spec/grape/validations/instance_behaivour_spec.rb b/spec/grape/validations/instance_behaivour_spec.rb deleted file mode 100644 index 9f2038dce..000000000 --- a/spec/grape/validations/instance_behaivour_spec.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe 'Validator with instance variables' do - let(:validator_type) do - Class.new(Grape::Validations::Base) do - def validate_param!(_attr_name, _params) - if instance_variable_defined?(:@instance_variable) && @instance_variable - raise Grape::Exceptions::Validation.new(params: ['params'], - message: 'This should never happen') - end - @instance_variable = true - end - end - end - - before do - Grape::Validations.register_validator('instance_validator', validator_type) - end - - after do - Grape::Validations.deregister_validator('instance_validator') - end - - let(:app) do - Class.new(Grape::API) do - params do - optional :param_to_validate, instance_validator: true - optional :another_param_to_validate, instance_validator: true - end - get do - 'noop' - end - end - end - - it 'passes validation every time' do - expect(validator_type).to receive(:new).exactly(4).times.and_call_original - - 2.times do - get '/', param_to_validate: 'value', another_param_to_validate: 'value' - expect(last_response.status).to eq 200 - end - end -end diff --git a/spec/grape/validations/multiple_attributes_iterator_spec.rb b/spec/grape/validations/multiple_attributes_iterator_spec.rb index 85f848207..14988b981 100644 --- a/spec/grape/validations/multiple_attributes_iterator_spec.rb +++ b/spec/grape/validations/multiple_attributes_iterator_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::MultipleAttributesIterator do describe '#each' do subject(:iterator) { described_class.new(validator, scope, params) } + let(:scope) { Grape::Validations::ParamsScope.new(api: Class.new(Grape::API)) } let(:validator) { double(attrs: %i[first second third]) } @@ -27,5 +26,13 @@ expect { |b| iterator.each(&b) }.to yield_successive_args(params[0], params[1]) end end + + context 'when params is empty optional placeholder' do + let(:params) { [Grape::DSL::Parameters::EmptyOptionalValue] } + + it 'does not yield it' do + expect { |b| iterator.each(&b) }.to yield_successive_args + end + end end end diff --git a/spec/grape/validations/params_scope_spec.rb b/spec/grape/validations/params_scope_spec.rb index 984524c8f..3caec4dd7 100644 --- a/spec/grape/validations/params_scope_spec.rb +++ b/spec/grape/validations/params_scope_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::ParamsScope do subject do Class.new(Grape::API) @@ -11,93 +9,14 @@ def app subject end - context 'setting a default' do - let(:documentation) { subject.routes.first.params } - - context 'when the default value is truthy' do - before do - subject.params do - optional :int, type: Integer, default: 42 - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('int') - expect(documentation['int']).to have_key(:default) - expect(documentation['int'][:default]).to eq(42) - end - end - - context 'when the default value is false' do - before do - subject.params do - optional :bool, type: Grape::API::Boolean, default: false - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('bool') - expect(documentation['bool']).to have_key(:default) - expect(documentation['bool'][:default]).to eq(false) - end - end - - context 'when the default value is nil' do - before do - subject.params do - optional :object, type: Object, default: nil - end - subject.get - end - - it 'adds documentation about the default value' do - expect(documentation).to have_key('object') - expect(documentation['object']).to have_key(:default) - expect(documentation['object'][:default]).to eq(nil) - end - end - end - - context 'without a default' do - before do - subject.params do - optional :object, type: Object - end - subject.get - end - - it 'does not add documentation for the default value' do - documentation = subject.routes.first.params - expect(documentation).to have_key('object') - expect(documentation['object']).not_to have_key(:default) - end - end - - context 'setting description' do - %i[desc description].each do |description_type| - it "allows setting #{description_type}" do - subject.params do - requires :int, type: Integer, description_type => 'My very nice integer' - end - subject.get '/single' do - 'int works' - end - get '/single', int: 420 - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('int works') - end - end - end - context 'when using custom types' do - module ParamsScopeSpec - class CustomType + let(:custom_type) do + Class.new do attr_reader :value def self.parse(value) raise if value == 'invalid' + new(value) end @@ -108,8 +27,9 @@ def initialize(value) end it 'coerces the parameter via the type\'s parse method' do + context = self subject.params do - requires :foo, type: ParamsScopeSpec::CustomType + requires :foo, type: context.custom_type end subject.get('/types') { params[:foo].value } @@ -138,13 +58,13 @@ def initialize(value) it do subject.params do - requires :foo, as: :bar, type: String, coerce_with: ->(c) { c.strip } + requires :foo, as: :bar, type: String, coerce_with: lambda(&:strip) end subject.get('/renaming-coerced') { "#{params['bar']}-#{params['foo']}" } get '/renaming-coerced', foo: ' there we go ' expect(last_response.status).to eq(200) - expect(last_response.body).to eq('there we go-') + expect(last_response.body).to eq('-there we go') end it do @@ -180,6 +100,28 @@ def initialize(value) expect(last_response.status).to eq(200) expect(last_response.body).to eq('{"baz":{"qux":"any"}}') end + + it 'renaming can be defined before default' do + subject.params do + optional :foo, as: :bar, default: 'before' + end + subject.get('/rename-before-default') { declared(params)[:bar] } + get '/rename-before-default' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('before') + end + + it 'renaming can be defined after default' do + subject.params do + optional :foo, default: 'after', as: :bar + end + subject.get('/rename-after-default') { declared(params)[:bar] } + get '/rename-after-default' + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq('after') + end end context 'array without coerce type explicitly given' do @@ -238,7 +180,7 @@ def initialize(value) end it 'allows the proc to pass validation without checking in except' do - subject.params { requires :numbers, type: Integer, values: { except: -> { [0, 1, 2] } } } + subject.params { requires :numbers, type: Integer, except_values: -> { [0, 1, 2] } } subject.post('/required') { 'coercion with proc works' } post '/required', numbers: '10' @@ -247,6 +189,24 @@ def initialize(value) end end + context 'a Set with coerce type explicitly given' do + context 'and the values are allowed' do + it 'does not raise an exception' do + expect do + subject.params { optional :numbers, type: Set[Integer], values: 0..2, default: 0..2 } + end.not_to raise_error + end + end + + context 'and the values are not allowed' do + it 'raises exception' do + expect do + subject.params { optional :numbers, type: Set[Integer], values: %w[a b] } + end.to raise_error Grape::Exceptions::IncompatibleOptionValues + end + end + end + context 'with range values' do context "when left range endpoint isn't #kind_of? the type" do it 'raises exception' do @@ -269,7 +229,7 @@ def initialize(value) it 'does not raise an exception' do expect do subject.params { optional :numbers, type: Array[Integer], values: 0..2, default: 0..2 } - end.to_not raise_error + end.not_to raise_error end end @@ -277,7 +237,7 @@ def initialize(value) it 'does not raise an exception' do expect do subject.params { optional :numbers, type: Array[Integer], values: [0, 1, 2], default: [1, 0] } - end.to_not raise_error + end.not_to raise_error end end end @@ -315,7 +275,7 @@ def initialize(value) requires :b end end - end.to raise_error Grape::Exceptions::MissingGroupTypeError + end.to raise_error Grape::Exceptions::MissingGroupType expect do subject.params do @@ -323,7 +283,7 @@ def initialize(value) requires :b end end - end.to raise_error Grape::Exceptions::MissingGroupTypeError + end.to raise_error Grape::Exceptions::MissingGroupType end it 'allows Hash as type' do @@ -383,7 +343,7 @@ def initialize(value) requires :b end end - end.to raise_error Grape::Exceptions::UnsupportedGroupTypeError + end.to raise_error Grape::Exceptions::UnsupportedGroupType expect do subject.params do @@ -391,7 +351,7 @@ def initialize(value) requires :b end end - end.to raise_error Grape::Exceptions::UnsupportedGroupTypeError + end.to raise_error Grape::Exceptions::UnsupportedGroupType end end @@ -501,7 +461,7 @@ def initialize(value) requires :c end end - end.to_not raise_error + end.not_to raise_error end it 'does not raise an error if when using nested given' do @@ -517,7 +477,7 @@ def initialize(value) end end end - end.to_not raise_error + end.not_to raise_error end it 'allows nested dependent parameters' do @@ -562,13 +522,13 @@ def initialize(value) body = JSON.parse(last_response.body) expect(body.keys).to include('c') - expect(body.keys).to_not include('b') + expect(body.keys).not_to include('b') end it 'allows renaming of dependent on parameter' do subject.params do optional :a, as: :b - given b: ->(val) { val == 'x' } do + given a: ->(val) { val == 'x' } do requires :c end end @@ -582,7 +542,7 @@ def initialize(value) expect(last_response.status).to eq 200 end - it 'raises an error if the dependent parameter is not the renamed one' do + it 'does not raise if the dependent parameter is not the renamed one' do expect do subject.params do optional :a, as: :b @@ -590,6 +550,17 @@ def initialize(value) requires :c end end + end.not_to raise_error + end + + it 'raises an error if the dependent parameter is the renamed one' do + expect do + subject.params do + optional :a, as: :b + given :b do + requires :c + end + end end.to raise_error(Grape::Exceptions::UnknownParameter) end @@ -633,6 +604,203 @@ def initialize(value) expect(last_response.status).to eq(200) end + it 'detect unmet nested dependency' do + subject.params do + requires :a, type: String, allow_blank: false, values: %w[x y z] + given a: ->(val) { val == 'z' } do + requires :inner3, type: Array, allow_blank: false do + requires :bar, type: String, allow_blank: false + given bar: ->(val) { val == 'b' } do + requires :baz, type: Array do + optional :baz_category, type: String + end + end + given bar: ->(val) { val == 'c' } do + requires :baz, type: Array do + requires :baz_category, type: String + end + end + end + end + end + subject.get('/nested-dependency') { declared(params).to_json } + + get '/nested-dependency', a: 'z', inner3: [{ bar: 'c', baz: [{ unrelated: 'nope' }] }] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq 'inner3[0][baz][0][baz_category] is missing' + end + + context 'detect when json array' do + before do + subject.params do + requires :array, type: Array do + requires :a, type: String + given a: ->(val) { val == 'a' } do + requires :json, type: Hash do + requires :b, type: String + end + end + end + end + + subject.post '/nested_array' do + 'nested array works!' + end + end + + it 'succeeds' do + params = { + array: [ + { + a: 'a', + json: { b: 'b' } + }, + { + a: 'b' + } + ] + } + post '/nested_array', params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(201) + end + + it 'fails' do + params = { + array: [ + { + a: 'a', + json: { b: 'b' } + }, + { + a: 'a' + } + ] + } + post '/nested_array', params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('array[1][json] is missing, array[1][json][b] is missing') + end + end + + context 'array without given' do + before do + subject.params do + requires :array, type: Array do + requires :a, type: Integer + requires :b, type: Integer + end + end + + subject.post '/array_without_given' + end + + it 'fails' do + params = { + array: [ + { + a: 1, + b: 2 + }, + { + a: 3 + }, + { + a: 5 + } + ] + } + post '/array_without_given', params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(last_response.body).to eq('array[1][b] is missing, array[2][b] is missing') + expect(last_response.status).to eq(400) + end + end + + context 'array with given' do + before do + subject.params do + requires :array, type: Array do + requires :a, type: Integer + given a: lambda(&:odd?) do + requires :b, type: Integer + end + end + end + + subject.post '/array_with_given' + end + + it 'fails' do + params = { + array: [ + { + a: 1, + b: 2 + }, + { + a: 3 + }, + { + a: 5 + } + ] + } + post '/array_with_given', params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(last_response.body).to eq('array[1][b] is missing, array[2][b] is missing') + expect(last_response.status).to eq(400) + end + end + + context 'nested json array with given' do + before do + subject.params do + requires :workflow_nodes, type: Array do + requires :steps, type: Array do + requires :id, type: String + optional :type, type: String, values: %w[send_messsge assign_user assign_team tag_conversation snooze close add_commit] + given type: ->(val) { val == 'send_messsge' } do + requires :message, type: Hash do + requires :content, type: String + end + end + end + end + end + + subject.post '/nested_json_array_with_given' + end + + it 'passes' do + params = { + workflow_nodes: [ + { + id: 'eqibmvEzPo8hQOSt', + title: 'Node 1', + is_start: true, + steps: [ + { + id: 'DvdSZaIm1hEd5XO5', + type: 'send_messsge', + message: { + content: '打击好', + menus: [] + } + }, + { + id: 'VY6MIwycBw0b51Ib', + type: 'add_commit', + comment_content: '初来乍到' + } + ] + } + ] + } + post '/nested_json_array_with_given', params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(201) + end + end + it 'includes the parameter within #declared(params)' do get '/test', a: true, b: true @@ -686,6 +854,317 @@ def initialize(value) get '/nested', bar: { a: true, c: { b: 'yes' } } expect(JSON.parse(last_response.body)).to eq('bar' => { 'a' => 'true', 'c' => { 'b' => 'yes' } }) end + + context 'when the dependent parameter is not present #declared(params)' do + context 'lateral parameter' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :a + given :a do + optional :b + end + end + subject.get("/evaluate_given_#{evaluate_given}") { declared(params, evaluate_given: evaluate_given).to_json } + end + end + + it 'evaluate_given_false' do + get '/evaluate_given_false', b: 'b' + expect(JSON.parse(last_response.body)).to eq('a' => nil, 'b' => 'b') + end + + it 'evaluate_given_true' do + get '/evaluate_given_true', b: 'b' + expect(JSON.parse(last_response.body)).to eq('a' => nil) + end + end + + context 'lateral hash parameter' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :a, values: %w[x y] + given a: ->(a) { a == 'x' } do + optional :b, type: Hash do + optional :c + end + optional :e + end + given a: ->(a) { a == 'y' } do + optional :b, type: Hash do + optional :d + end + optional :f + end + end + subject.get("/evaluate_given_#{evaluate_given}") { declared(params, evaluate_given: evaluate_given).to_json } + end + end + + it 'evaluate_given_false' do + get '/evaluate_given_false', a: 'x' + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'd' => nil }, 'e' => nil, 'f' => nil) + + get '/evaluate_given_false', a: 'y' + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => nil }, 'e' => nil, 'f' => nil) + end + + it 'evaluate_given_true' do + get '/evaluate_given_true', a: 'x' + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'c' => nil }, 'e' => nil) + + get '/evaluate_given_true', a: 'y' + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => nil }, 'f' => nil) + end + end + + context 'lateral parameter within lateral hash parameter' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :a, values: %w[x y] + given a: ->(a) { a == 'x' } do + optional :b, type: Hash do + optional :c + given :c do + optional :g + optional :e, type: Hash do + optional :h + end + end + end + end + given a: ->(a) { a == 'y' } do + optional :b, type: Hash do + optional :d + given :d do + optional :f + optional :e, type: Hash do + optional :i + end + end + end + end + end + subject.get("/evaluate_given_#{evaluate_given}") { declared(params, evaluate_given: evaluate_given).to_json } + end + end + + it 'evaluate_given_false' do + get '/evaluate_given_false', a: 'x' + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'd' => nil, 'f' => nil, 'e' => { 'i' => nil } }) + + get '/evaluate_given_false', a: 'x', b: { c: 'c' } + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'd' => nil, 'f' => nil, 'e' => { 'i' => nil } }) + + get '/evaluate_given_false', a: 'y' + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => nil, 'f' => nil, 'e' => { 'i' => nil } }) + + get '/evaluate_given_false', a: 'y', b: { d: 'd' } + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => 'd', 'f' => nil, 'e' => { 'i' => nil } }) + end + + it 'evaluate_given_true' do + get '/evaluate_given_true', a: 'x' + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'c' => nil }) + + get '/evaluate_given_true', a: 'x', b: { c: 'c' } + expect(JSON.parse(last_response.body)).to eq('a' => 'x', 'b' => { 'c' => 'c', 'g' => nil, 'e' => { 'h' => nil } }) + + get '/evaluate_given_true', a: 'y' + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => nil }) + + get '/evaluate_given_true', a: 'y', b: { d: 'd' } + expect(JSON.parse(last_response.body)).to eq('a' => 'y', 'b' => { 'd' => 'd', 'f' => nil, 'e' => { 'i' => nil } }) + end + end + + context 'lateral parameter within an array param' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :array, type: Array do + optional :a + given :a do + optional :b + end + end + end + subject.post("/evaluate_given_#{evaluate_given}") do + declared(params, evaluate_given: evaluate_given).to_json + end + end + end + + it 'evaluate_given_false' do + post '/evaluate_given_false', { array: [{ b: 'b' }, { a: 'a', b: 'b' }] }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('array' => [{ 'a' => nil, 'b' => 'b' }, { 'a' => 'a', 'b' => 'b' }]) + end + + it 'evaluate_given_true' do + post '/evaluate_given_true', { array: [{ b: 'b' }, { a: 'a', b: 'b' }] }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('array' => [{ 'a' => nil }, { 'a' => 'a', 'b' => 'b' }]) + end + end + + context 'nested given parameter' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :a + optional :c + given :a do + given :c do + optional :b + end + end + end + subject.post("/evaluate_given_#{evaluate_given}") do + declared(params, evaluate_given: evaluate_given).to_json + end + end + end + + it 'evaluate_given_false' do + post '/evaluate_given_false', { a: 'a', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => 'a', 'b' => 'b', 'c' => nil) + + post '/evaluate_given_false', { c: 'c', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => nil, 'b' => 'b', 'c' => 'c') + + post '/evaluate_given_false', { a: 'a', c: 'c', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => 'a', 'b' => 'b', 'c' => 'c') + end + + it 'evaluate_given_true' do + post '/evaluate_given_true', { a: 'a', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => 'a', 'c' => nil) + + post '/evaluate_given_true', { c: 'c', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => nil, 'c' => 'c') + + post '/evaluate_given_true', { a: 'a', c: 'c', b: 'b' }.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('a' => 'a', 'b' => 'b', 'c' => 'c') + end + end + + context 'nested given parameter within an array param' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :array, type: Array do + optional :a + optional :c + given :a do + given :c do + optional :b + end + end + end + end + subject.post("/evaluate_given_#{evaluate_given}") do + declared(params, evaluate_given: evaluate_given).to_json + end + end + end + + let :evaluate_given_params do + { + array: [ + { a: 'a', b: 'b' }, + { c: 'c', b: 'b' }, + { a: 'a', c: 'c', b: 'b' } + ] + } + end + + it 'evaluate_given_false' do + post '/evaluate_given_false', evaluate_given_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('array' => [{ 'a' => 'a', 'b' => 'b', 'c' => nil }, { 'a' => nil, 'b' => 'b', 'c' => 'c' }, { 'a' => 'a', 'b' => 'b', 'c' => 'c' }]) + end + + it 'evaluate_given_true' do + post '/evaluate_given_true', evaluate_given_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq('array' => [{ 'a' => 'a', 'c' => nil }, { 'a' => nil, 'c' => 'c' }, { 'a' => 'a', 'b' => 'b', 'c' => 'c' }]) + end + end + + context 'nested given parameter within a nested given parameter within an array param' do + before do + [true, false].each do |evaluate_given| + subject.params do + optional :array, type: Array do + optional :a + optional :c + given :a do + given :c do + optional :array, type: Array do + optional :a + optional :c + given :a do + given :c do + optional :b + end + end + end + end + end + end + end + subject.post("/evaluate_given_#{evaluate_given}") do + declared(params, evaluate_given: evaluate_given).to_json + end + end + end + + let :evaluate_given_params do + { + array: [{ + a: 'a', + c: 'c', + array: [ + { a: 'a', b: 'b' }, + { c: 'c', b: 'b' }, + { a: 'a', c: 'c', b: 'b' } + ] + }] + } + end + + it 'evaluate_given_false' do + expected_response_hash = { + 'array' => [{ + 'a' => 'a', + 'c' => 'c', + 'array' => [ + { 'a' => 'a', 'b' => 'b', 'c' => nil }, + { 'a' => nil, 'c' => 'c', 'b' => 'b' }, + { 'a' => 'a', 'c' => 'c', 'b' => 'b' } + ] + }] + } + post '/evaluate_given_false', evaluate_given_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq(expected_response_hash) + end + + it 'evaluate_given_true' do + expected_response_hash = { + 'array' => [{ + 'a' => 'a', + 'c' => 'c', + 'array' => [ + { 'a' => 'a', 'c' => nil }, + { 'a' => nil, 'c' => 'c' }, + { 'a' => 'a', 'b' => 'b', 'c' => 'c' } + ] + }] + } + post '/evaluate_given_true', evaluate_given_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(JSON.parse(last_response.body)).to eq(expected_response_hash) + end + end + end end context 'default value in given block' do @@ -727,7 +1206,7 @@ def initialize(value) subject.get('/test') { 'ok' } end - it 'should pass none Hash params' do + it 'passes none Hash params' do get '/test', foos: [''] expect(last_response.status).to eq(200) expect(last_response.body).to eq('ok') @@ -903,6 +1382,7 @@ def initialize(value) expect(last_response.body).to eq('one is missing, two is missing, three is missing') end end + context 'when fail_fast is defined it stops the validation' do it 'of other params' do subject.params do @@ -915,6 +1395,7 @@ def initialize(value) expect(last_response.status).to eq(400) expect(last_response.body).to eq('one is missing') end + it 'for a single param' do subject.params do requires :one, allow_blank: false, regexp: /[0-9]+/, fail_fast: true @@ -965,7 +1446,7 @@ def initialize(value) end it 'prioritizes parameter validation over group validation' do - expect(last_response.body).to_not include('address is empty') + expect(last_response.body).not_to include('address is empty') end end end @@ -1089,6 +1570,96 @@ def initialize(value) end end end + + context 'with many levels of nested groups' do + before do + subject.params do + requires :first_level, type: Hash do + with(type: Integer) do + requires :value + with(type: String) do + optional :second_level, type: Array do + optional :name, type: String + with(type: Integer) do + optional :third_level, type: Array do + requires :value, type: Integer + optional :position + end + end + end + end + requires :id + end + end + end + subject.put('/nested') { declared(params).to_json } + end + + context 'when data is valid' do + let(:request_params) do + { + first_level: { + value: '10', + second_level: [ + { + name: '13', + third_level: [ + { + value: '2', + position: '1' + } + ] + } + ], + id: '20' + } + } + end + + it 'validates and coerces correctly' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body, symbolize_names: true)).to eq( + first_level: { + value: 10, + second_level: [ + { name: '13', third_level: [{ value: 2, position: 1 }] } + ], + id: 20 + } + ) + end + end + + context 'when data is invalid' do + let(:request_params) do + { + first_level: { + value: 'wrong', + second_level: [ + { name: 'name', third_level: [{ position: 'wrong' }] } + ] + } + } + end + + it 'responds with HTTP error' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + expect(last_response.status).to eq(400) + end + + it 'responds with a validation error' do + put '/nested', request_params.to_json, 'CONTENT_TYPE' => 'application/json' + + expect(last_response.body) + .to include('first_level[value] is invalid') + .and include('first_level[id] is missing') + .and include('first_level[second_level][0][third_level][0][value] is missing') + .and include('first_level[second_level][0][third_level][0][position] is invalid') + end + end + end end context 'with exactly_one_of validation for optional parameters within an Hash param' do diff --git a/spec/grape/validations/single_attribute_iterator_spec.rb b/spec/grape/validations/single_attribute_iterator_spec.rb index 2b3edbf06..2cde615e3 100644 --- a/spec/grape/validations/single_attribute_iterator_spec.rb +++ b/spec/grape/validations/single_attribute_iterator_spec.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::SingleAttributeIterator do describe '#each' do subject(:iterator) { described_class.new(validator, scope, params) } + let(:scope) { Grape::Validations::ParamsScope.new(api: Class.new(Grape::API)) } let(:validator) { double(attrs: %i[first second]) } @@ -42,6 +41,16 @@ ) end end + + context 'when missing optional value' do + let(:params) { [Grape::DSL::Parameters::EmptyOptionalValue, 10] } + + it 'does not yield skipped values' do + expect { |b| iterator.each(&b) }.to yield_successive_args( + [params[1], :first, false], [params[1], :second, false] + ) + end + end end end end diff --git a/spec/grape/validations/types/array_coercer_spec.rb b/spec/grape/validations/types/array_coercer_spec.rb index f2bfb6c6d..14cca7e58 100644 --- a/spec/grape/validations/types/array_coercer_spec.rb +++ b/spec/grape/validations/types/array_coercer_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::Types::ArrayCoercer do subject { described_class.new(type) } diff --git a/spec/grape/validations/types/primitive_coercer_spec.rb b/spec/grape/validations/types/primitive_coercer_spec.rb index df375ac37..cc0dd1995 100644 --- a/spec/grape/validations/types/primitive_coercer_spec.rb +++ b/spec/grape/validations/types/primitive_coercer_spec.rb @@ -1,18 +1,16 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::Types::PrimitiveCoercer do - let(:strict) { false } - subject { described_class.new(type, strict) } + let(:strict) { false } + describe '#call' do context 'BigDecimal' do let(:type) { BigDecimal } it 'coerces to BigDecimal' do - expect(subject.call(5)).to eq(BigDecimal(5)) + expect(subject.call(5)).to eq(BigDecimal('5')) end it 'coerces an empty string to nil' do @@ -25,13 +23,13 @@ [true, 'true', 1].each do |val| it "coerces '#{val}' to true" do - expect(subject.call(val)).to eq(true) + expect(subject.call(val)).to be(true) end end [false, 'false', 0].each do |val| it "coerces '#{val}' to false" do - expect(subject.call(val)).to eq(false) + expect(subject.call(val)).to be(false) end end @@ -66,6 +64,10 @@ it 'coerces an empty string to nil' do expect(subject.call('')).to be_nil end + + it 'accepts non-nil value' do + expect(subject.call(42)).to be_a(Integer) + end end context 'Numeric' do @@ -74,6 +76,10 @@ it 'coerces an empty string to nil' do expect(subject.call('')).to be_nil end + + it 'accepts a non-nil value' do + expect(subject.call(42)).to be_a(Numeric) # in fact Integer + end end context 'Time' do @@ -104,6 +110,15 @@ end end + context 'a type unknown in Dry-types' do + let(:type) { Complex } + + it 'raises error on init' do + expect(DryTypes::Params.constants).not_to include(type.name.to_sym) + expect { subject }.to raise_error(/type Complex should support coercion/) + end + end + context 'the strict mode' do let(:strict) { true } @@ -115,7 +130,7 @@ end it 'returns a value as it is when the given value is Boolean' do - expect(subject.call(true)).to eq(true) + expect(subject.call(true)).to be(true) end end @@ -127,7 +142,7 @@ end it 'returns a value as it is when the given value is BigDecimal' do - expect(subject.call(BigDecimal(0))).to eq(BigDecimal(0)) + expect(subject.call(BigDecimal('0'))).to eq(BigDecimal('0')) end end end diff --git a/spec/grape/validations/types/set_coercer_spec.rb b/spec/grape/validations/types/set_coercer_spec.rb index d78f5f511..47646a00a 100644 --- a/spec/grape/validations/types/set_coercer_spec.rb +++ b/spec/grape/validations/types/set_coercer_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::Types::SetCoercer do subject { described_class.new(type) } diff --git a/spec/grape/validations/types_spec.rb b/spec/grape/validations/types_spec.rb index 1bee89e77..003088a43 100644 --- a/spec/grape/validations/types_spec.rb +++ b/spec/grape/validations/types_spec.rb @@ -1,14 +1,13 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations::Types do - module TypesSpec - class FooType + let(:foo_type) do + Class.new do def self.parse(_); end end - - class BarType + end + let(:bar_type) do + Class.new do def self.parse; end end end @@ -20,13 +19,13 @@ def self.parse; end Date, DateTime, Time ].each do |type| it "recognizes #{type} as a primitive" do - expect(described_class.primitive?(type)).to be_truthy + expect(described_class).to be_primitive(type) end end it 'identifies unknown types' do - expect(described_class.primitive?(Object)).to be_falsy - expect(described_class.primitive?(TypesSpec::FooType)).to be_falsy + expect(described_class).not_to be_primitive(Object) + expect(described_class).not_to be_primitive(foo_type) end end @@ -35,7 +34,7 @@ def self.parse; end Hash, Array, Set ].each do |type| it "recognizes #{type} as a structure" do - expect(described_class.structure?(type)).to be_truthy + expect(described_class).to be_structure(type) end end end @@ -45,22 +44,50 @@ def self.parse; end JSON, Array[JSON], File, Rack::Multipart::UploadedFile ].each do |type| it "provides special handling for #{type.inspect}" do - expect(described_class.special?(type)).to be_truthy + expect(described_class).to be_special(type) end end end + describe 'special types' do + subject { described_class::SPECIAL[type] } + + context 'when JSON' do + let(:type) { JSON } + + it { is_expected.to eq(Grape::Validations::Types::Json) } + end + + context 'when Array[JSON]' do + let(:type) { Array[JSON] } + + it { is_expected.to eq(Grape::Validations::Types::JsonArray) } + end + + context 'when File' do + let(:type) { File } + + it { is_expected.to eq(Grape::Validations::Types::File) } + end + + context 'when Rack::Multipart::UploadedFile' do + let(:type) { Rack::Multipart::UploadedFile } + + it { is_expected.to eq(Grape::Validations::Types::File) } + end + end + describe '::custom?' do it 'returns false if the type does not respond to :parse' do - expect(described_class.custom?(Object)).to be_falsy + expect(described_class).not_to be_custom(Object) end it 'returns true if the type responds to :parse with one argument' do - expect(described_class.custom?(TypesSpec::FooType)).to be_truthy + expect(described_class).to be_custom(foo_type) end it 'returns false if the type\'s #parse method takes other than one argument' do - expect(described_class.custom?(TypesSpec::BarType)).to be_falsy + expect(described_class).not_to be_custom(bar_type) end end diff --git a/spec/grape/validations/validators/all_or_none_spec.rb b/spec/grape/validations/validators/all_or_none_validator_spec.rb similarity index 66% rename from spec/grape/validations/validators/all_or_none_spec.rb rename to spec/grape/validations/validators/all_or_none_validator_spec.rb index ce4124946..9c8fe78b1 100644 --- a/spec/grape/validations/validators/all_or_none_spec.rb +++ b/spec/grape/validations/validators/all_or_none_validator_spec.rb @@ -1,74 +1,66 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::AllOrNoneOfValidator do - describe '#validate!' do - subject(:validate) { post path, params } - - module ValidationsSpec - module AllOrNoneOfValidatorSpec - class API < Grape::API - rescue_from Grape::Exceptions::ValidationErrors do |e| - error!(e.errors.transform_keys! { |key| key.join(',') }, 400) - end +describe Grape::Validations::Validators::AllOrNoneOfValidator do + let_it_be(:app) do + Class.new(Grape::API) do + rescue_from Grape::Exceptions::ValidationErrors do |e| + error!(e.errors.transform_keys! { |key| key.join(',') }, 400) + end - params do - optional :beer, :wine, type: Boolean - all_or_none_of :beer, :wine - end - post do - end + params do + optional :beer, :wine, type: Grape::API::Boolean + all_or_none_of :beer, :wine + end + post do + end - params do - optional :beer, :wine, :other, type: Boolean - all_or_none_of :beer, :wine - end - post 'mixed-params' do - end + params do + optional :beer, :wine, :other, type: Grape::API::Boolean + all_or_none_of :beer, :wine + end + post 'mixed-params' do + end - params do - optional :beer, :wine, type: Boolean - all_or_none_of :beer, :wine, message: 'choose all or none' - end - post '/custom-message' do - end + params do + optional :beer, :wine, type: Grape::API::Boolean + all_or_none_of :beer, :wine, message: 'choose all or none' + end + post '/custom-message' do + end - params do - requires :item, type: Hash do - optional :beer, :wine, type: Boolean - all_or_none_of :beer, :wine - end - end - post '/nested-hash' do - end + params do + requires :item, type: Hash do + optional :beer, :wine, type: Grape::API::Boolean + all_or_none_of :beer, :wine + end + end + post '/nested-hash' do + end - params do - requires :items, type: Array do - optional :beer, :wine, type: Boolean - all_or_none_of :beer, :wine - end - end - post '/nested-array' do - end + params do + requires :items, type: Array do + optional :beer, :wine, type: Grape::API::Boolean + all_or_none_of :beer, :wine + end + end + post '/nested-array' do + end - params do - requires :items, type: Array do - requires :nested_items, type: Array do - optional :beer, :wine, type: Boolean - all_or_none_of :beer, :wine - end - end - end - post '/deeply-nested-array' do + params do + requires :items, type: Array do + requires :nested_items, type: Array do + optional :beer, :wine, type: Grape::API::Boolean + all_or_none_of :beer, :wine end end end + post '/deeply-nested-array' do + end end + end - def app - ValidationsSpec::AllOrNoneOfValidatorSpec::API - end + describe '#validate!' do + subject(:validate) { post path, params } context 'when all restricted params are present' do let(:path) { '/' } diff --git a/spec/grape/validations/validators/allow_blank_spec.rb b/spec/grape/validations/validators/allow_blank_validator_spec.rb similarity index 77% rename from spec/grape/validations/validators/allow_blank_spec.rb rename to spec/grape/validations/validators/allow_blank_validator_spec.rb index cb74e332c..88c527508 100644 --- a/spec/grape/validations/validators/allow_blank_spec.rb +++ b/spec/grape/validations/validators/allow_blank_validator_spec.rb @@ -1,25 +1,138 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::Validations::Validators::AllowBlankValidator do + let_it_be(:app) do + Class.new(Grape::API) do + default_format :json -describe Grape::Validations::AllowBlankValidator do - module ValidationsSpec - module AllowBlankValidatorSpec - class API < Grape::API - default_format :json + params do + requires :name, allow_blank: false + end + get '/disallow_blank' - params do + params do + optional :name, type: String, allow_blank: false + end + get '/opt_disallow_string_blank' + + params do + optional :name, allow_blank: false + end + get '/disallow_blank_optional_param' + + params do + requires :name, allow_blank: true + end + get '/allow_blank' + + params do + requires :val, type: DateTime, allow_blank: true + end + get '/allow_datetime_blank' + + params do + requires :val, type: DateTime, allow_blank: false + end + get '/disallow_datetime_blank' + + params do + requires :val, type: DateTime + end + get '/default_allow_datetime_blank' + + params do + requires :val, type: Date, allow_blank: true + end + get '/allow_date_blank' + + params do + requires :val, type: Integer, allow_blank: true + end + get '/allow_integer_blank' + + params do + requires :val, type: Float, allow_blank: true + end + get '/allow_float_blank' + + params do + requires :val, type: Integer, allow_blank: true + end + get '/allow_integer_blank' + + params do + requires :val, type: Symbol, allow_blank: true + end + get '/allow_symbol_blank' + + params do + requires :val, type: Grape::API::Boolean, allow_blank: true + end + get '/allow_boolean_blank' + + params do + requires :val, type: Grape::API::Boolean, allow_blank: false + end + get '/disallow_boolean_blank' + + params do + optional :user, type: Hash do + requires :name, allow_blank: false + end + end + get '/disallow_blank_required_param_in_an_optional_group' + + params do + optional :user, type: Hash do + requires :name, type: Date, allow_blank: true + end + end + get '/allow_blank_date_param_in_an_optional_group' + + params do + optional :user, type: Hash do + optional :name, allow_blank: false + requires :age + end + end + get '/disallow_blank_optional_param_in_an_optional_group' + + params do + requires :user, type: Hash do + requires :name, allow_blank: false + end + end + get '/disallow_blank_required_param_in_a_required_group' + + params do + requires :user, type: Hash do requires :name, allow_blank: false end - get '/disallow_blank' + end + get '/disallow_string_value_in_a_required_hash_group' + + params do + requires :user, type: Hash do + optional :name, allow_blank: false + end + end + get '/disallow_blank_optional_param_in_a_required_group' + params do + optional :user, type: Hash do + optional :name, allow_blank: false + end + end + get '/disallow_string_value_in_an_optional_hash_group' + + resources :custom_message do params do - optional :name, type: String, allow_blank: false + requires :name, allow_blank: { value: false, message: 'has no value' } end - get '/opt_disallow_string_blank' + get params do - optional :name, allow_blank: false + optional :name, allow_blank: { value: false, message: 'has no value' } end get '/disallow_blank_optional_param' @@ -34,7 +147,7 @@ class API < Grape::API get '/allow_datetime_blank' params do - requires :val, type: DateTime, allow_blank: false + requires :val, type: DateTime, allow_blank: { value: false, message: 'has no value' } end get '/disallow_datetime_blank' @@ -69,18 +182,18 @@ class API < Grape::API get '/allow_symbol_blank' params do - requires :val, type: Boolean, allow_blank: true + requires :val, type: Grape::API::Boolean, allow_blank: true end get '/allow_boolean_blank' params do - requires :val, type: Boolean, allow_blank: false + requires :val, type: Grape::API::Boolean, allow_blank: { value: false, message: 'has no value' } end get '/disallow_boolean_blank' params do optional :user, type: Hash do - requires :name, allow_blank: false + requires :name, allow_blank: { value: false, message: 'has no value' } end end get '/disallow_blank_required_param_in_an_optional_group' @@ -94,7 +207,7 @@ class API < Grape::API params do optional :user, type: Hash do - optional :name, allow_blank: false + optional :name, allow_blank: { value: false, message: 'has no value' } requires :age end end @@ -102,154 +215,52 @@ class API < Grape::API params do requires :user, type: Hash do - requires :name, allow_blank: false + requires :name, allow_blank: { value: false, message: 'has no value' } end end get '/disallow_blank_required_param_in_a_required_group' params do requires :user, type: Hash do - requires :name, allow_blank: false + requires :name, allow_blank: { value: false, message: 'has no value' } end end get '/disallow_string_value_in_a_required_hash_group' params do requires :user, type: Hash do - optional :name, allow_blank: false + optional :name, allow_blank: { value: false, message: 'has no value' } end end get '/disallow_blank_optional_param_in_a_required_group' params do optional :user, type: Hash do - optional :name, allow_blank: false + optional :name, allow_blank: { value: false, message: 'has no value' } end end get '/disallow_string_value_in_an_optional_hash_group' + end + end + end - resources :custom_message do - params do - requires :name, allow_blank: { value: false, message: 'has no value' } - end - get - - params do - optional :name, allow_blank: { value: false, message: 'has no value' } - end - get '/disallow_blank_optional_param' - - params do - requires :name, allow_blank: true - end - get '/allow_blank' - - params do - requires :val, type: DateTime, allow_blank: true - end - get '/allow_datetime_blank' - - params do - requires :val, type: DateTime, allow_blank: { value: false, message: 'has no value' } - end - get '/disallow_datetime_blank' - - params do - requires :val, type: DateTime - end - get '/default_allow_datetime_blank' - - params do - requires :val, type: Date, allow_blank: true - end - get '/allow_date_blank' - - params do - requires :val, type: Integer, allow_blank: true - end - get '/allow_integer_blank' - - params do - requires :val, type: Float, allow_blank: true - end - get '/allow_float_blank' - - params do - requires :val, type: Integer, allow_blank: true - end - get '/allow_integer_blank' - - params do - requires :val, type: Symbol, allow_blank: true - end - get '/allow_symbol_blank' - - params do - requires :val, type: Boolean, allow_blank: true - end - get '/allow_boolean_blank' - - params do - requires :val, type: Boolean, allow_blank: { value: false, message: 'has no value' } - end - get '/disallow_boolean_blank' - - params do - optional :user, type: Hash do - requires :name, allow_blank: { value: false, message: 'has no value' } - end - end - get '/disallow_blank_required_param_in_an_optional_group' - - params do - optional :user, type: Hash do - requires :name, type: Date, allow_blank: true - end - end - get '/allow_blank_date_param_in_an_optional_group' - - params do - optional :user, type: Hash do - optional :name, allow_blank: { value: false, message: 'has no value' } - requires :age - end - end - get '/disallow_blank_optional_param_in_an_optional_group' - - params do - requires :user, type: Hash do - requires :name, allow_blank: { value: false, message: 'has no value' } - end - end - get '/disallow_blank_required_param_in_a_required_group' - - params do - requires :user, type: Hash do - requires :name, allow_blank: { value: false, message: 'has no value' } - end - end - get '/disallow_string_value_in_a_required_hash_group' - - params do - requires :user, type: Hash do - optional :name, allow_blank: { value: false, message: 'has no value' } - end - end - get '/disallow_blank_optional_param_in_a_required_group' + describe 'bad encoding' do + let(:app) do + Class.new(Grape::API) do + default_format :json - params do - optional :user, type: Hash do - optional :name, allow_blank: { value: false, message: 'has no value' } - end - end - get '/disallow_string_value_in_an_optional_hash_group' + params do + requires :name, type: String, allow_blank: false end + get '/bad_encoding' end end - end - def app - ValidationsSpec::AllowBlankValidatorSpec::API + context 'when value has bad encoding' do + it 'does not raise an error' do + expect { get('/bad_encoding', { name: "Hello \x80" }) }.not_to raise_error + end + end end context 'invalid input' do @@ -289,10 +300,12 @@ def app get '/custom_message', name: '' expect(last_response.body).to eq('{"error":"name has no value"}') end + it 'refuses empty string for an optional param' do get '/custom_message/disallow_blank_optional_param', name: '' expect(last_response.body).to eq('{"error":"name has no value"}') end + it 'refuses only whitespaces' do get '/custom_message', name: ' ' expect(last_response.body).to eq('{"error":"name has no value"}') diff --git a/spec/grape/validations/validators/at_least_one_of_spec.rb b/spec/grape/validations/validators/at_least_one_of_validator_spec.rb similarity index 72% rename from spec/grape/validations/validators/at_least_one_of_spec.rb rename to spec/grape/validations/validators/at_least_one_of_validator_spec.rb index b6468e3e5..4b0e3b0c7 100644 --- a/spec/grape/validations/validators/at_least_one_of_spec.rb +++ b/spec/grape/validations/validators/at_least_one_of_validator_spec.rb @@ -1,74 +1,66 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::AtLeastOneOfValidator do - describe '#validate!' do - subject(:validate) { post path, params } - - module ValidationsSpec - module AtLeastOneOfValidatorSpec - class API < Grape::API - rescue_from Grape::Exceptions::ValidationErrors do |e| - error!(e.errors.transform_keys! { |key| key.join(',') }, 400) - end +describe Grape::Validations::Validators::AtLeastOneOfValidator do + let_it_be(:app) do + Class.new(Grape::API) do + rescue_from Grape::Exceptions::ValidationErrors do |e| + error!(e.errors.transform_keys! { |key| key.join(',') }, 400) + end - params do - optional :beer, :wine, :grapefruit - at_least_one_of :beer, :wine, :grapefruit - end - post do - end + params do + optional :beer, :wine, :grapefruit + at_least_one_of :beer, :wine, :grapefruit + end + post do + end - params do - optional :beer, :wine, :grapefruit, :other - at_least_one_of :beer, :wine, :grapefruit - end - post 'mixed-params' do - end + params do + optional :beer, :wine, :grapefruit, :other + at_least_one_of :beer, :wine, :grapefruit + end + post 'mixed-params' do + end - params do - optional :beer, :wine, :grapefruit - at_least_one_of :beer, :wine, :grapefruit, message: 'you should choose something' - end - post '/custom-message' do - end + params do + optional :beer, :wine, :grapefruit + at_least_one_of :beer, :wine, :grapefruit, message: 'you should choose something' + end + post '/custom-message' do + end - params do - requires :item, type: Hash do - optional :beer, :wine, :grapefruit - at_least_one_of :beer, :wine, :grapefruit, message: 'fail' - end - end - post '/nested-hash' do - end + params do + requires :item, type: Hash do + optional :beer, :wine, :grapefruit + at_least_one_of :beer, :wine, :grapefruit, message: 'fail' + end + end + post '/nested-hash' do + end - params do - requires :items, type: Array do - optional :beer, :wine, :grapefruit - at_least_one_of :beer, :wine, :grapefruit, message: 'fail' - end - end - post '/nested-array' do - end + params do + requires :items, type: Array do + optional :beer, :wine, :grapefruit + at_least_one_of :beer, :wine, :grapefruit, message: 'fail' + end + end + post '/nested-array' do + end - params do - requires :items, type: Array do - requires :nested_items, type: Array do - optional :beer, :wine, :grapefruit - at_least_one_of :beer, :wine, :grapefruit, message: 'fail' - end - end - end - post '/deeply-nested-array' do + params do + requires :items, type: Array do + requires :nested_items, type: Array do + optional :beer, :wine, :grapefruit + at_least_one_of :beer, :wine, :grapefruit, message: 'fail' end end end + post '/deeply-nested-array' do + end end + end - def app - ValidationsSpec::AtLeastOneOfValidatorSpec::API - end + describe '#validate!' do + subject(:validate) { post path, params } context 'when all restricted params are present' do let(:path) { '/' } diff --git a/spec/grape/validations/validators/coerce_spec.rb b/spec/grape/validations/validators/coerce_validator_spec.rb similarity index 72% rename from spec/grape/validations/validators/coerce_spec.rb rename to spec/grape/validations/validators/coerce_validator_spec.rb index 41acb5f0d..81cd4b511 100644 --- a/spec/grape/validations/validators/coerce_spec.rb +++ b/spec/grape/validations/validators/coerce_validator_spec.rb @@ -1,29 +1,25 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::Validations::Validators::CoerceValidator do + subject { Class.new(Grape::API) } -describe Grape::Validations::CoerceValidator do - subject do - Class.new(Grape::API) - end - - def app - subject - end + let(:app) { subject } describe 'coerce' do - class SecureURIOnly - def self.parse(value) - URI.parse(value) - end + let(:secure_uri_only) do + Class.new do + def self.parse(value) + URI.parse(value) + end - def self.parsed?(value) - value.is_a? URI::HTTPS + def self.parsed?(value) + value.is_a? URI::HTTPS + end end end context 'i18n' do - after :each do + after do I18n.available_locales = %i[en] I18n.locale = :en I18n.default_locale = :en @@ -31,9 +27,9 @@ def self.parsed?(value) it 'i18n error on malformed input' do I18n.available_locales = %i[en zh-CN] - I18n.load_path << File.expand_path('../zh-CN.yml', __FILE__) + I18n.load_path << File.expand_path('zh-CN.yml', __dir__) I18n.reload! - I18n.locale = 'zh-CN'.to_sym + I18n.locale = :'zh-CN' subject.params do requires :age, type: Integer end @@ -42,13 +38,13 @@ def self.parsed?(value) end get '/single', age: '43a' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('年龄格式不正确') end it 'gives an english fallback error when default locale message is blank' do I18n.available_locales = %i[en pt-BR] - I18n.locale = 'pt-BR'.to_sym + I18n.locale = :'pt-BR' subject.params do requires :age, type: Integer end @@ -57,7 +53,7 @@ def self.parsed?(value) end get '/single', age: '43a' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('age is invalid') end end @@ -72,21 +68,22 @@ def self.parsed?(value) end get '/single', int: '43a' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('int type cast is invalid') get '/single', int: '43' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('int works') end context 'on custom coercion rules' do before do subject.params do - requires :a, types: { value: [Boolean, String], message: 'type cast is invalid' }, coerce_with: (lambda do |val| - if val == 'yup' + requires :a, types: { value: [Grape::API::Boolean, String], message: 'type cast is invalid' }, coerce_with: (lambda do |val| + case val + when 'yup' true - elsif val == 'false' + when 'false' 0 else val @@ -101,19 +98,19 @@ def self.parsed?(value) it 'respects :coerce_with' do get '/', a: 'yup' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') end it 'still validates type' do get '/', a: 'false' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('a type cast is invalid') end it 'performs no additional coercion' do get '/', a: 'true' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('String') end end @@ -128,11 +125,11 @@ def self.parsed?(value) end get '/single', int: '43a' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('int is invalid') get '/single', int: '43' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('int works') end @@ -145,11 +142,11 @@ def self.parsed?(value) end get 'array', ids: %w[1 2 az] - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('ids is invalid') get 'array', ids: %w[1 2 890] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('array int works') end @@ -166,20 +163,20 @@ def self.parsed?(value) end post '/bigdecimal', { bigdecimal: 45.1 }.to_json, headers - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq('BigDecimal 45.1') end - it 'Boolean' do + it 'Grape::API::Boolean' do subject.params do - requires :boolean, type: Boolean + requires :boolean, type: Grape::API::Boolean end subject.post '/boolean' do params[:boolean] end post '/boolean', { boolean: 'true' }.to_json, headers - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq('true') end end @@ -193,7 +190,7 @@ def self.parsed?(value) end get '/bigdecimal', bigdecimal: '45' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('BigDecimal') end @@ -206,7 +203,7 @@ def self.parsed?(value) end get '/int', int: '45' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq(integer_class_name) end @@ -219,31 +216,60 @@ def self.parsed?(value) end get '/string', string: 45 - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('String') get '/string', string: nil - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end - it 'is a custom type' do - subject.params do - requires :uri, coerce: SecureURIOnly - end - subject.get '/secure_uri' do - params[:uri].class + context 'a custom type' do + it 'coerces the given value' do + context = self + subject.params do + requires :uri, coerce: context.secure_uri_only + end + subject.get '/secure_uri' do + params[:uri].class + end + + get 'secure_uri', uri: 'https://www.example.com' + + expect(last_response).to be_successful + expect(last_response.body).to eq('URI::HTTPS') + + get 'secure_uri', uri: 'http://www.example.com' + + expect(last_response).to be_bad_request + expect(last_response.body).to eq('uri is invalid') end - get 'secure_uri', uri: 'https://www.example.com' + context 'returning the InvalidValue instance when invalid' do + let(:custom_type) do + Class.new do + def self.parse(_val) + Grape::Types::InvalidValue.new('must be unique') + end + end + end - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('URI::HTTPS') + it 'uses a custom message added to the invalid value' do + type = custom_type - get 'secure_uri', uri: 'http://www.example.com' + subject.params do + requires :name, type: type + end + subject.get '/whatever' do + params[:name].class + end + + get 'whatever', name: 'Bob' - expect(last_response.status).to eq(400) - expect(last_response.body).to eq('uri is invalid') + expect(last_response).to be_bad_request + expect(last_response.body).to eq('name must be unique') + end + end end context 'Array' do @@ -256,7 +282,7 @@ def self.parsed?(value) end get '/array', arry: %w[1 2 3] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq(integer_class_name) end @@ -269,7 +295,7 @@ def self.parsed?(value) end get 'array', arry: [1, 0] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') end @@ -281,7 +307,7 @@ def self.parsed?(value) params[:uri][0].class end get 'uri_array', uri: ['http://www.google.com'] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('URI::HTTP') end @@ -293,22 +319,23 @@ def self.parsed?(value) "#{params[:uri].class},#{params[:uri].first.class},#{params[:uri].size}" end get 'uri_array', uri: Array.new(2) { 'http://www.example.com' } - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('Set,URI::HTTP,1') end it 'Array of a custom type' do + context = self subject.params do - requires :uri, type: Array[SecureURIOnly] + requires :uri, type: Array[context.secure_uri_only] end subject.get '/secure_uris' do params[:uri].first.class end get 'secure_uris', uri: ['https://www.example.com'] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('URI::HTTPS') get 'secure_uris', uri: ['https://www.example.com', 'http://www.example.com'] - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('uri is invalid') end end @@ -323,7 +350,7 @@ def self.parsed?(value) end get '/set', set: Set.new([1, 2, 3, 4]).to_a - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq(integer_class_name) end @@ -336,21 +363,21 @@ def self.parsed?(value) end get '/set', set: Set.new([1, 0]).to_a - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') end end - it 'Boolean' do + it 'Grape::API::Boolean' do subject.params do - requires :boolean, type: Boolean + requires :boolean, type: Grape::API::Boolean end subject.get '/boolean' do params[:boolean].class end get '/boolean', boolean: 1 - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') end @@ -367,11 +394,11 @@ def self.parsed?(value) end post '/upload', file: file - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq(filename) post '/upload', file: 'not a file' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('file is invalid') end @@ -384,15 +411,15 @@ def self.parsed?(value) end post '/upload', file: file - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq(filename) post '/upload', file: 'not a file' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('file is invalid') post '/upload', file: { filename: 'fake file', tempfile: '/etc/passwd' } - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('file is invalid') end @@ -405,7 +432,7 @@ def self.parsed?(value) end post '/upload', files: [file] - expect(last_response.status).to eq(201) + expect(last_response).to be_created expect(last_response.body).to eq(filename) end end @@ -421,7 +448,7 @@ def self.parsed?(value) end get '/int', integers: { int: '45' } - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq(integer_class_name) end @@ -437,7 +464,7 @@ def self.parsed?(value) end get '/nil_value', param: nil - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -454,7 +481,7 @@ def self.parsed?(value) end get '/nil_value', param: nil - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -471,7 +498,7 @@ def self.parsed?(value) end get '/nil_value', param: nil - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -490,7 +517,7 @@ def self.parsed?(value) end get '/nil_value', param: nil - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -510,7 +537,7 @@ def self.parsed?(value) end get '/empty_string', param: '' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -524,7 +551,7 @@ def self.parsed?(value) end get '/empty_string', param: '' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('String') end end @@ -540,7 +567,7 @@ def self.parsed?(value) end get '/empty_string', param: '' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -557,7 +584,7 @@ def self.parsed?(value) end get '/empty_string', param: '' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -576,7 +603,7 @@ def self.parsed?(value) end get '/empty_string', param: '' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end end @@ -595,11 +622,11 @@ def self.parsed?(value) end get '/ints', values: '1 2 3 4' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([1, 2, 3, 4]) get '/ints', values: 'a b c d' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([0, 0, 0, 0]) end @@ -612,14 +639,38 @@ def self.parsed?(value) end get '/strings', values: '1 2 3 4' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq(%w[1 2 3 4]) get '/strings', values: 'a b c d' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq(%w[a b c d]) end + it 'parses parameters with Array[Array[String]] type and coerce_with' do + subject.params do + requires :values, type: Array[Array[String]], coerce_with: ->(val) { val.is_a?(String) ? [val.split(',').map(&:strip)] : val } + end + subject.post '/coerce_nested_strings' do + params[:values] + end + + post '/coerce_nested_strings', Grape::Json.dump(values: 'a,b,c,d'), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_created + expect(JSON.parse(last_response.body)).to eq([%w[a b c d]]) + + post '/coerce_nested_strings', Grape::Json.dump(values: [%w[a c], %w[b]]), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_created + expect(JSON.parse(last_response.body)).to eq([%w[a c], %w[b]]) + + post '/coerce_nested_strings', Grape::Json.dump(values: [[]]), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_created + expect(JSON.parse(last_response.body)).to eq([[]]) + + post '/coerce_nested_strings', Grape::Json.dump(values: [['a', { bar: 0 }], ['b']]), 'CONTENT_TYPE' => 'application/json' + expect(last_response).to be_bad_request + end + it 'parses parameters with Array[Integer] type' do subject.params do requires :values, type: Array[Integer], coerce_with: ->(val) { val.split(/\s+/).map(&:to_i) } @@ -629,11 +680,11 @@ def self.parsed?(value) end get '/ints', values: '1 2 3 4' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([1, 2, 3, 4]) get '/ints', values: 'a b c d' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([0, 0, 0, 0]) end @@ -646,14 +697,52 @@ def self.parsed?(value) end get '/ints', values: [1, 2, 3, 4] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([2, 3, 4, 5]) get '/ints', values: %w[a b c d] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(JSON.parse(last_response.body)).to eq([1, 1, 1, 1]) end + context 'Array type and coerce_with should' do + before do + subject.params do + optional :arr, type: Array, coerce_with: (lambda do |val| + if val.nil? + [] + else + val + end + end) + end + subject.get '/' do + params[:arr].class.to_s + end + end + + it 'coerce nil value to array' do + get '/', arr: nil + + expect(last_response).to be_successful + expect(last_response.body).to eq('Array') + end + + it 'not coerce missing field' do + get '/' + + expect(last_response).to be_successful + expect(last_response.body).to eq('NilClass') + end + + it 'coerce array as array' do + get '/', arr: [] + + expect(last_response).to be_successful + expect(last_response.body).to eq('Array') + end + end + it 'uses parse where available' do subject.params do requires :ints, type: Array, coerce_with: JSON do @@ -667,15 +756,15 @@ def self.parsed?(value) end get '/ints', ints: [{ i: 1, j: '2' }] - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('ints is invalid') get '/ints', ints: '{"i":1,"j":"2"}' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('ints is invalid') get '/ints', ints: '[{"i":"1","j":"2"}]' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('coercion works') end @@ -690,25 +779,64 @@ def self.parsed?(value) end get '/ints', ints: '{"int":"3"}' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('ints[int] is invalid') get '/ints', ints: '{"int":"three"}' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('3') get '/ints', ints: '{"int":3}' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('3') end + context 'Integer type and coerce_with should' do + before do + subject.params do + optional :int, type: Integer, coerce_with: (lambda do |val| + if val.nil? + 0 + else + val.to_i + end + end) + end + subject.get '/' do + params[:int].class.to_s + end + end + + it 'coerce nil value to integer' do + get '/', int: nil + + expect(last_response).to be_successful + expect(last_response.body).to eq('Integer') + end + + it 'not coerce missing field' do + get '/' + + expect(last_response).to be_successful + expect(last_response.body).to eq('NilClass') + end + + it 'coerce integer as integer' do + get '/', int: 1 + + expect(last_response).to be_successful + expect(last_response.body).to eq('Integer') + end + end + context 'Integer type and coerce_with potentially returning nil' do before do subject.params do requires :int, type: Integer, coerce_with: (lambda do |val| - if val == '0' + case val + when '0' nil - elsif val.match?(/^-?\d+$/) + when /^-?\d+$/ val.to_i else val @@ -723,21 +851,21 @@ def self.parsed?(value) it 'accepts value that coerces to nil' do get '/', int: '0' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('NilClass') end it 'coerces to Integer' do get '/', int: '1' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('Integer') end it 'returns invalid value if coercion returns a wrong type' do get '/', int: 'lol' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('int is invalid') end end @@ -771,35 +899,35 @@ def self.parsed?(value) end get '/', splines: '{"x":1,"ints":[1,2,3],"obj":{"y":"woof"}}' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('woof') get '/', splines: { x: 1, ints: [1, 2, 3], obj: { y: 'woof' } } - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('woof') get '/', splines: '[{"x":2,"ints":[]},{"x":3,"ints":[4],"obj":{"y":"quack"}}]' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('arrays work') get '/', splines: [{ x: 2, ints: [5] }, { x: 3, ints: [4], obj: { y: 'quack' } }] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('arrays work') get '/', splines: '{"x":4,"ints":[2]}' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') get '/', splines: { x: 4, ints: [2] } - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') get '/', splines: '[{"x":1,"ints":[]},{"x":4,"ints":[]}]' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') get '/', splines: [{ x: 1, ints: [5] }, { x: 4, ints: [6] }] - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') end @@ -822,19 +950,19 @@ def self.parsed?(value) end get '/', splines: '{"x":1,"ints":[1,2,3],"obj":{"y":"woof"}}' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('woof') get '/', splines: '[{"x":2,"ints":[]},{"x":3,"ints":[4],"obj":{"y":"quack"}}]' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('arrays work') get '/', splines: '{"x":4,"ints":[2]}' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') get '/', splines: '[{"x":1,"ints":[]},{"x":4,"ints":[]}]' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') end @@ -852,19 +980,19 @@ def self.parsed?(value) end get '/', splines: '{"x":"1","y":"woof"}' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq("#{integer_class_name}.String") get '/', splines: '[{"x":1,"y":2},{"x":1,"y":"quack"}]' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq("#{integer_class_name}.#{integer_class_name}") get '/', splines: '{"x":"4","y":"woof"}' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') get '/', splines: '[{"x":"4","y":"woof"}]' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('splines[x] does not have a valid value') end @@ -888,43 +1016,41 @@ def self.parsed?(value) end context 'multiple types' do - Boolean = Grape::API::Boolean - it 'coerces to first possible type' do subject.params do - requires :a, types: [Boolean, Integer, String] + requires :a, types: [Grape::API::Boolean, Integer, String] end subject.get '/' do params[:a].class.to_s end get '/', a: 'true' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') get '/', a: '5' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq(integer_class_name) get '/', a: 'anything else' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('String') end it 'fails when no coercion is possible' do subject.params do - requires :a, types: [Boolean, Integer] + requires :a, types: [Grape::API::Boolean, Integer] end subject.get '/' do params[:a].class.to_s end get '/', a: true - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') get '/', a: 'not good' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('a is invalid') end @@ -948,134 +1074,63 @@ def self.parsed?(value) it 'allows singular form declaration' do get '/', a: 'one way' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('"one way"') get '/', a: %w[the other] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('["the", "other"]') get '/', a: { a: 1, b: 2 } - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('a is invalid') get '/', a: [1, 2, 3] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('["1", "2", "3"]') end it 'allows multiple collection types' do get '/', b: [1, 2, 3] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('[1, 2, 3]') get '/', b: %w[1 2 3] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('[1, 2, 3]') get '/', b: [1, true, 'three'] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('["1", "true", "three"]') end it 'allows collections with multiple types' do get '/', c: [1, '2', true, 'three'] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('[1, 2, "true", "three"]') get '/', d: '1' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('1') get '/', d: 'one' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('"one"') get '/', d: %w[1 two] - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('#') end end - context 'when params is Hashie::Mash' do - context 'for primitive collections' do - before do - subject.params do - build_with Grape::Extensions::Hashie::Mash::ParamBuilder - optional :a, types: [String, Array[String]] - optional :b, types: [Array[Integer], Array[String]] - optional :c, type: Array[Integer, String] - optional :d, types: [Integer, String, Set[Integer, String]] - end - subject.get '/' do - ( - params.a || - params.b || - params.c || - params.d - ).inspect - end - end - - it 'allows singular form declaration' do - get '/', a: 'one way' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('"one way"') - - get '/', a: %w[the other] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - - get '/', a: { a: 1, b: 2 } - expect(last_response.status).to eq(400) - expect(last_response.body).to eq('a is invalid') - - get '/', a: [1, 2, 3] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - end - - it 'allows multiple collection types' do - get '/', b: [1, 2, 3] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - - get '/', b: %w[1 2 3] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - - get '/', b: [1, true, 'three'] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - end - - it 'allows collections with multiple types' do - get '/', c: [1, '2', true, 'three'] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - - get '/', d: '1' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('1') - - get '/', d: 'one' - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('"one"') - - get '/', d: %w[1 two] - expect(last_response.status).to eq(200) - expect(last_response.body).to eq('#') - end - end - end - context 'custom coercion rules' do before do subject.params do - requires :a, types: [Boolean, String], coerce_with: (lambda do |val| - if val == 'yup' + requires :a, types: [Grape::API::Boolean, String], coerce_with: (lambda do |val| + case val + when 'yup' true - elsif val == 'false' + when 'false' 0 else val @@ -1089,19 +1144,19 @@ def self.parsed?(value) it 'respects :coerce_with' do get '/', a: 'yup' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('TrueClass') end it 'still validates type' do get '/', a: 'false' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('a is invalid') end it 'performs no additional coercion' do get '/', a: 'true' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('String') end end diff --git a/spec/grape/validations/validators/contract_scope_validator_spec.rb b/spec/grape/validations/validators/contract_scope_validator_spec.rb new file mode 100644 index 000000000..b8d462c31 --- /dev/null +++ b/spec/grape/validations/validators/contract_scope_validator_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::ContractScopeValidator do + describe '.inherits' do + subject { described_class } + + it { is_expected.to be < Grape::Validations::Validators::Base } + end +end diff --git a/spec/grape/validations/validators/default_spec.rb b/spec/grape/validations/validators/default_validator_spec.rb similarity index 71% rename from spec/grape/validations/validators/default_spec.rb rename to spec/grape/validations/validators/default_validator_spec.rb index 10220eb58..df84f06ee 100644 --- a/spec/grape/validations/validators/default_spec.rb +++ b/spec/grape/validations/validators/default_validator_spec.rb @@ -1,103 +1,108 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::DefaultValidator do - module ValidationsSpec - module DefaultValidatorSpec - class API < Grape::API - default_format :json - - params do - optional :id - optional :type, default: 'default-type' - end - get '/' do - { id: params[:id], type: params[:type] } - end +describe Grape::Validations::Validators::DefaultValidator do + let_it_be(:app) do + Class.new(Grape::API) do + default_format :json + + params do + optional :id + optional :type, default: 'default-type' + end + get '/' do + { id: params[:id], type: params[:type] } + end - params do - optional :type1, default: 'default-type1' - optional :type2, default: 'default-type2' - end - get '/user' do - { type1: params[:type1], type2: params[:type2] } - end + params do + optional :type1, default: 'default-type1' + optional :type2, default: 'default-type2' + end + get '/user' do + { type1: params[:type1], type2: params[:type2] } + end - params do - requires :id - optional :type1, default: 'default-type1' - optional :type2, default: 'default-type2' - end + params do + requires :id + optional :type1, default: 'default-type1' + optional :type2, default: 'default-type2' + end - get '/message' do - { id: params[:id], type1: params[:type1], type2: params[:type2] } - end + get '/message' do + { id: params[:id], type1: params[:type1], type2: params[:type2] } + end - params do - optional :random, default: -> { Random.rand } - optional :not_random, default: Random.rand - end - get '/numbers' do - { random_number: params[:random], non_random_number: params[:non_random_number] } - end + params do + optional :random, default: -> { Random.rand } + optional :not_random, default: Random.rand + end + get '/numbers' do + { random_number: params[:random], non_random_number: params[:non_random_number] } + end - params do - optional :array, type: Array do - requires :name - optional :with_default, default: 'default' - end - end - get '/array' do - { array: params[:array] } + params do + optional :array, type: Array do + requires :name + optional :with_default, default: 'default' end + end + get '/array' do + { array: params[:array] } + end - params do - requires :thing1 - optional :more_things, type: Array do - requires :nested_thing - requires :other_thing, default: 1 - end - end - get '/optional_array' do - { thing1: params[:thing1] } + params do + requires :thing1 + optional :more_things, type: Array do + requires :nested_thing + requires :other_thing, default: 1 end + end + get '/optional_array' do + { thing1: params[:thing1] } + end - params do - requires :root, type: Hash do - optional :some_things, type: Array do - requires :foo - optional :options, type: Array do - requires :name, type: String - requires :value, type: String - end + params do + requires :root, type: Hash do + optional :some_things, type: Array do + requires :foo + optional :options, type: Array do + requires :name, type: String + requires :value, type: String end end end - get '/nested_optional_array' do - { root: params[:root] } - end + end + get '/nested_optional_array' do + { root: params[:root] } + end - params do - requires :root, type: Hash do - optional :some_things, type: Array do - requires :foo - optional :options, type: Array do - optional :name, type: String - optional :value, type: String - end + params do + requires :root, type: Hash do + optional :some_things, type: Array do + requires :foo + optional :options, type: Array do + optional :name, type: String + optional :value, type: String end end end - get '/another_nested_optional_array' do - { root: params[:root] } - end end - end - end + get '/another_nested_optional_array' do + { root: params[:root] } + end - def app - ValidationsSpec::DefaultValidatorSpec::API + params do + requires :foo + optional :bar, default: ->(params) { params[:foo] } + optional :qux, default: ->(params) { params[:bar] } + end + get '/default_values_from_other_params' do + { + foo: params[:foo], + bar: params[:bar], + qux: params[:qux] + } + end + end end it 'lets you leave required values nested inside an optional blank' do @@ -377,8 +382,8 @@ def app [JSON, { test: 'non-empty-string' }.to_json], [Array[JSON], []], [Array[JSON], [{ test: 'non-empty-string' }.to_json]], - [::File, ''], - [::File, { test: 'non-empty-string' }.to_json], + [File, ''], + [File, { test: 'non-empty-string' }.to_json], [Rack::Multipart::UploadedFile, ''], [Rack::Multipart::UploadedFile, { test: 'non-empty-string' }.to_json] ].each do |type, default| @@ -419,4 +424,66 @@ def app end end end + + context 'array with default values and given conditions' do + subject do + Class.new(Grape::API) do + default_format :json + end + end + + def app + subject + end + + it 'applies the default values only if the conditions are met' do + subject.params do + requires :ary, type: Array do + requires :has_value, type: Grape::API::Boolean + given has_value: ->(has_value) { has_value } do + optional :type, type: String, values: %w[str int], default: 'str' + given type: ->(type) { type == 'str' } do + optional :str, type: String, default: 'a' + end + given type: ->(type) { type == 'int' } do + optional :int, type: Integer, default: 1 + end + end + end + end + subject.post('/nested_given_and_default') { declared(self.params) } + + params = { + ary: [ + { has_value: false }, + { has_value: true, type: 'int', int: 123 }, + { has_value: true, type: 'str', str: 'b' } + ] + } + expected = { + 'ary' => [ + { 'has_value' => false, 'type' => nil, 'int' => nil, 'str' => nil }, + { 'has_value' => true, 'type' => 'int', 'int' => 123, 'str' => nil }, + { 'has_value' => true, 'type' => 'str', 'int' => nil, 'str' => 'b' } + ] + } + + post '/nested_given_and_default', params + expect(last_response.status).to eq(201) + expect(JSON.parse(last_response.body)).to eq(expected) + end + end + + it 'sets default value for optional params using other params values' do + expected_foo_value = 'foo-value' + + get("/default_values_from_other_params?foo=#{expected_foo_value}") + + expect(last_response.status).to eq(200) + expect(last_response.body).to eq({ + foo: expected_foo_value, + bar: expected_foo_value, + qux: expected_foo_value + }.to_json) + end end diff --git a/spec/grape/validations/validators/exactly_one_of_spec.rb b/spec/grape/validations/validators/exactly_one_of_validator_spec.rb similarity index 67% rename from spec/grape/validations/validators/exactly_one_of_spec.rb rename to spec/grape/validations/validators/exactly_one_of_validator_spec.rb index 87eba59d3..21e177e93 100644 --- a/spec/grape/validations/validators/exactly_one_of_spec.rb +++ b/spec/grape/validations/validators/exactly_one_of_validator_spec.rb @@ -1,96 +1,88 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::ExactlyOneOfValidator do - describe '#validate!' do - subject(:validate) { post path, params } - - module ValidationsSpec - module ExactlyOneOfValidatorSpec - class API < Grape::API - rescue_from Grape::Exceptions::ValidationErrors do |e| - error!(e.errors.transform_keys! { |key| key.join(',') }, 400) - end +describe Grape::Validations::Validators::ExactlyOneOfValidator do + let_it_be(:app) do + Class.new(Grape::API) do + rescue_from Grape::Exceptions::ValidationErrors do |e| + error!(e.errors.transform_keys! { |key| key.join(',') }, 400) + end - params do - optional :beer - optional :wine - optional :grapefruit - exactly_one_of :beer, :wine, :grapefruit - end - post do - end + params do + optional :beer + optional :wine + optional :grapefruit + exactly_one_of :beer, :wine, :grapefruit + end + post do + end - params do - optional :beer - optional :wine - optional :grapefruit - optional :other - exactly_one_of :beer, :wine, :grapefruit - end - post 'mixed-params' do - end + params do + optional :beer + optional :wine + optional :grapefruit + optional :other + exactly_one_of :beer, :wine, :grapefruit + end + post 'mixed-params' do + end - params do - optional :beer - optional :wine - optional :grapefruit - exactly_one_of :beer, :wine, :grapefruit, message: 'you should choose one' - end - post '/custom-message' do - end + params do + optional :beer + optional :wine + optional :grapefruit + exactly_one_of :beer, :wine, :grapefruit, message: 'you should choose one' + end + post '/custom-message' do + end - params do - requires :item, type: Hash do - optional :beer - optional :wine - optional :grapefruit - exactly_one_of :beer, :wine, :grapefruit - end - end - post '/nested-hash' do - end + params do + requires :item, type: Hash do + optional :beer + optional :wine + optional :grapefruit + exactly_one_of :beer, :wine, :grapefruit + end + end + post '/nested-hash' do + end - params do - optional :item, type: Hash do - optional :beer - optional :wine - optional :grapefruit - exactly_one_of :beer, :wine, :grapefruit - end - end - post '/nested-optional-hash' do - end + params do + optional :item, type: Hash do + optional :beer + optional :wine + optional :grapefruit + exactly_one_of :beer, :wine, :grapefruit + end + end + post '/nested-optional-hash' do + end - params do - requires :items, type: Array do - optional :beer - optional :wine - optional :grapefruit - exactly_one_of :beer, :wine, :grapefruit - end - end - post '/nested-array' do - end + params do + requires :items, type: Array do + optional :beer + optional :wine + optional :grapefruit + exactly_one_of :beer, :wine, :grapefruit + end + end + post '/nested-array' do + end - params do - requires :items, type: Array do - requires :nested_items, type: Array do - optional :beer, :wine, :grapefruit, type: Boolean - exactly_one_of :beer, :wine, :grapefruit - end - end - end - post '/deeply-nested-array' do + params do + requires :items, type: Array do + requires :nested_items, type: Array do + optional :beer, :wine, :grapefruit, type: Grape::API::Boolean + exactly_one_of :beer, :wine, :grapefruit end end end + post '/deeply-nested-array' do + end end + end - def app - ValidationsSpec::ExactlyOneOfValidatorSpec::API - end + describe '#validate!' do + subject(:validate) { post path, params } context 'when all params are present' do let(:path) { '/' } diff --git a/spec/grape/validations/validators/except_values_spec.rb b/spec/grape/validations/validators/except_values_spec.rb deleted file mode 100644 index 1bdbfc805..000000000 --- a/spec/grape/validations/validators/except_values_spec.rb +++ /dev/null @@ -1,193 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -describe Grape::Validations::ExceptValuesValidator do - module ValidationsSpec - class ExceptValuesModel - DEFAULT_EXCEPTS = ['invalid-type1', 'invalid-type2', 'invalid-type3'].freeze - class << self - attr_accessor :excepts - def excepts - @excepts ||= [] - [DEFAULT_EXCEPTS + @excepts].flatten.uniq - end - end - end - - TEST_CASES = { - req_except: { - requires: { except_values: ExceptValuesModel.excepts }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } - ] - }, - req_except_hash: { - requires: { except_values: { value: ExceptValuesModel.excepts } }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } - ] - }, - req_except_custom_message: { - requires: { except_values: { value: ExceptValuesModel.excepts, message: 'is not allowed' } }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type is not allowed' }.to_json }, - { value: 'invalid-type3', rc: 400, body: { error: 'type is not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } - ] - }, - req_except_no_value: { - requires: { except_values: { message: 'is not allowed' } }, - tests: [ - { value: 'invalid-type1', rc: 200, body: { type: 'invalid-type1' }.to_json } - ] - }, - req_except_empty: { - requires: { except_values: [] }, - tests: [ - { value: 'invalid-type1', rc: 200, body: { type: 'invalid-type1' }.to_json } - ] - }, - req_except_lambda: { - requires: { except_values: -> { ExceptValuesModel.excepts } }, - add_excepts: ['invalid-type4'], - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'invalid-type4', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } - ] - }, - req_except_lambda_custom_message: { - requires: { except_values: { value: -> { ExceptValuesModel.excepts }, message: 'is not allowed' } }, - add_excepts: ['invalid-type4'], - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type is not allowed' }.to_json }, - { value: 'invalid-type4', rc: 400, body: { error: 'type is not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } - ] - }, - opt_except_default: { - optional: { except_values: ExceptValuesModel.excepts, default: 'valid-type2' }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json }, - { rc: 200, body: { type: 'valid-type2' }.to_json } - ] - }, - opt_except_lambda_default: { - optional: { except_values: -> { ExceptValuesModel.excepts }, default: 'valid-type2' }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json }, - { rc: 200, body: { type: 'valid-type2' }.to_json } - ] - }, - req_except_type_coerce: { - requires: { type: Integer, except_values: [10, 11] }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, - { value: 11, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: '11', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: '3', rc: 200, body: { type: 3 }.to_json }, - { value: 3, rc: 200, body: { type: 3 }.to_json } - ] - }, - opt_except_type_coerce_default: { - optional: { type: Integer, except_values: [10, 11], default: 12 }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, - { value: 10, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: '3', rc: 200, body: { type: 3 }.to_json }, - { value: 3, rc: 200, body: { type: 3 }.to_json }, - { rc: 200, body: { type: 12 }.to_json } - ] - }, - opt_except_array_type_coerce_default: { - optional: { type: Array[Integer], except_values: [10, 11], default: 12 }, - tests: [ - { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, - { value: 10, rc: 400, body: { error: 'type is invalid' }.to_json }, - { value: [10], rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: ['3'], rc: 200, body: { type: [3] }.to_json }, - { value: [3], rc: 200, body: { type: [3] }.to_json }, - { rc: 200, body: { type: 12 }.to_json } - ] - }, - req_except_range: { - optional: { type: Integer, except_values: 10..12 }, - tests: [ - { value: 11, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, - { value: 13, rc: 200, body: { type: 13 }.to_json } - ] - } - }.freeze - - module ExceptValidatorSpec - class API < Grape::API - default_format :json - - TEST_CASES.each_with_index do |(k, v), _i| - params do - requires :type, v[:requires] if v.key? :requires - optional :type, v[:optional] if v.key? :optional - end - get k do - { type: params[:type] } - end - end - end - end - end - - it 'raises IncompatibleOptionValues on a default value in exclude' do - subject = Class.new(Grape::API) - expect do - subject.params do - optional :type, except_values: ValidationsSpec::ExceptValuesModel.excepts, - default: ValidationsSpec::ExceptValuesModel.excepts.sample - end - end.to raise_error Grape::Exceptions::IncompatibleOptionValues - end - - it 'raises IncompatibleOptionValues when a default array has excluded values' do - subject = Class.new(Grape::API) - expect do - subject.params do - optional :type, type: Array[Integer], - except_values: 10..12, - default: [8, 9, 10] - end - end.to raise_error Grape::Exceptions::IncompatibleOptionValues - end - - it 'raises IncompatibleOptionValues when type is incompatible with values array' do - subject = Class.new(Grape::API) - expect do - subject.params { optional :type, except_values: ['valid-type1', 'valid-type2', 'valid-type3'], type: Symbol } - end.to raise_error Grape::Exceptions::IncompatibleOptionValues - end - - def app - ValidationsSpec::ExceptValidatorSpec::API - end - - ValidationsSpec::TEST_CASES.each_with_index do |(k, v), i| - v[:tests].each do |t| - it "#{i}: #{k} - #{t[:value]}" do - ValidationsSpec::ExceptValuesModel.excepts = v[:add_excepts] if v.key? :add_excepts - body = {} - body[:type] = t[:value] if t.key? :value - get k.to_s, **body - expect(last_response.status).to eq t[:rc] - expect(last_response.body).to eq t[:body] - ValidationsSpec::ExceptValuesModel.excepts = nil - end - end - end -end diff --git a/spec/grape/validations/validators/except_values_validator_spec.rb b/spec/grape/validations/validators/except_values_validator_spec.rb new file mode 100644 index 000000000..d72d0092d --- /dev/null +++ b/spec/grape/validations/validators/except_values_validator_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::ExceptValuesValidator do + describe 'IncompatibleOptionValues' do + subject { api } + + context 'when a default value is set' do + let(:api) do + ev = except_values + dv = default_value + Class.new(Grape::API) do + params do + optional :type, except_values: ev, default: dv + end + end + end + + context 'when default value is in exclude' do + let(:except_values) { 1..10 } + let(:default_value) { except_values.to_a.sample } + + it 'raises IncompatibleOptionValues' do + expect { subject }.to raise_error Grape::Exceptions::IncompatibleOptionValues + end + end + + context 'when default array has excluded values' do + let(:except_values) { 1..10 } + let(:default_value) { [8, 9, 10] } + + it 'raises IncompatibleOptionValues' do + expect { subject }.to raise_error Grape::Exceptions::IncompatibleOptionValues + end + end + end + + context 'when type is incompatible' do + let(:api) do + Class.new(Grape::API) do + params do + optional :type, except_values: 1..10, type: Symbol + end + end + end + + it 'raises IncompatibleOptionValues' do + expect { subject }.to raise_error Grape::Exceptions::IncompatibleOptionValues + end + end + end + + { + req_except: { + requires: { except_values: %w[invalid-type1 invalid-type2 invalid-type3] }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } + ] + }, + req_except_hash: { + requires: { except_values: { value: %w[invalid-type1 invalid-type2 invalid-type3] } }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } + ] + }, + req_except_custom_message: { + requires: { except_values: { value: %w[invalid-type1 invalid-type2 invalid-type3], message: 'is not allowed' } }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type is not allowed' }.to_json }, + { value: 'invalid-type3', rc: 400, body: { error: 'type is not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } + ] + }, + req_except_no_value: { + requires: { except_values: { message: 'is not allowed' } }, + tests: [ + { value: 'invalid-type1', rc: 200, body: { type: 'invalid-type1' }.to_json } + ] + }, + req_except_empty: { + requires: { except_values: [] }, + tests: [ + { value: 'invalid-type1', rc: 200, body: { type: 'invalid-type1' }.to_json } + ] + }, + req_except_lambda: { + requires: { except_values: -> { %w[invalid-type1 invalid-type2 invalid-type3 invalid-type4] } }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'invalid-type4', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } + ] + }, + req_except_lambda_custom_message: { + requires: { except_values: { value: -> { %w[invalid-type1 invalid-type2 invalid-type3 invalid-type4] }, message: 'is not allowed' } }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type is not allowed' }.to_json }, + { value: 'invalid-type4', rc: 400, body: { error: 'type is not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json } + ] + }, + opt_except_default: { + optional: { except_values: %w[invalid-type1 invalid-type2 invalid-type3], default: 'valid-type2' }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json }, + { rc: 200, body: { type: 'valid-type2' }.to_json } + ] + }, + opt_except_lambda_default: { + optional: { except_values: -> { %w[invalid-type1 invalid-type2 invalid-type3] }, default: 'valid-type2' }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'invalid-type3', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 'valid-type', rc: 200, body: { type: 'valid-type' }.to_json }, + { rc: 200, body: { type: 'valid-type2' }.to_json } + ] + }, + req_except_type_coerce: { + requires: { type: Integer, except_values: [10, 11] }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, + { value: 11, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: '11', rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: '3', rc: 200, body: { type: 3 }.to_json }, + { value: 3, rc: 200, body: { type: 3 }.to_json } + ] + }, + opt_except_type_coerce_default: { + optional: { type: Integer, except_values: [10, 11], default: 12 }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, + { value: 10, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: '3', rc: 200, body: { type: 3 }.to_json }, + { value: 3, rc: 200, body: { type: 3 }.to_json }, + { rc: 200, body: { type: 12 }.to_json } + ] + }, + opt_except_array_type_coerce_default: { + optional: { type: Array[Integer], except_values: [10, 11], default: 12 }, + tests: [ + { value: 'invalid-type1', rc: 400, body: { error: 'type is invalid' }.to_json }, + { value: 10, rc: 400, body: { error: 'type is invalid' }.to_json }, + { value: [10], rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: ['3'], rc: 200, body: { type: [3] }.to_json }, + { value: [3], rc: 200, body: { type: [3] }.to_json }, + { rc: 200, body: { type: 12 }.to_json } + ] + }, + req_except_range: { + optional: { type: Integer, except_values: 10..12 }, + tests: [ + { value: 11, rc: 400, body: { error: 'type has a value not allowed' }.to_json }, + { value: 13, rc: 200, body: { type: 13 }.to_json } + ] + } + }.each do |path, param_def| + param_def[:tests].each do |t| + describe "when #{path}" do + let(:app) do + Class.new(Grape::API) do + default_format :json + params do + requires :type, param_def[:requires] if param_def.key? :requires + optional :type, param_def[:optional] if param_def.key? :optional + end + get path do + { type: params[:type] } + end + end + end + + let(:body) do + {}.tap do |body| + body[:type] = t[:value] if t.key? :value + end + end + + before do + get path.to_s, **body + end + + it "returns body #{t[:body]} with status #{t[:rc]}" do + expect(last_response.status).to eq t[:rc] + expect(last_response.body).to eq t[:body] + end + end + end + end +end diff --git a/spec/grape/validations/validators/length_validator_spec.rb b/spec/grape/validations/validators/length_validator_spec.rb new file mode 100644 index 000000000..7e85b4dd8 --- /dev/null +++ b/spec/grape/validations/validators/length_validator_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +describe Grape::Validations::Validators::LengthValidator do + let_it_be(:app) do + Class.new(Grape::API) do + params do + requires :list, length: { min: 2, max: 3 } + end + post 'with_min_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2 } + end + post 'with_min_only' do + end + + params do + requires :list, type: [Integer], length: { max: 3 } + end + post 'with_max_only' do + end + + params do + requires :list, type: Integer, length: { max: 3 } + end + post 'type_is_not_array' do + end + + params do + requires :list, type: Hash, length: { max: 3 } + end + post 'type_supports_length' do + end + + params do + requires :list, type: [Integer], length: { min: -3 } + end + post 'negative_min' do + end + + params do + requires :list, type: [Integer], length: { max: -3 } + end + post 'negative_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2.5 } + end + post 'float_min' do + end + + params do + requires :list, type: [Integer], length: { max: 2.5 } + end + post 'float_max' do + end + + params do + requires :list, type: [Integer], length: { min: 15, max: 3 } + end + post 'min_greater_than_max' do + end + + params do + requires :list, type: [Integer], length: { min: 3, max: 3 } + end + post 'min_equal_to_max' do + end + + params do + requires :list, type: [JSON], length: { min: 0 } + end + post 'zero_min' do + end + + params do + requires :list, type: [JSON], length: { max: 0 } + end + post 'zero_max' do + end + + params do + requires :list, type: [Integer], length: { min: 2, message: 'not match' } + end + post '/custom-message' do + end + + params do + requires :code, length: { is: 2 } + end + post 'is' do + end + + params do + requires :code, length: { is: -2 } + end + post 'negative_is' do + end + + params do + requires :code, length: { is: 2, max: 10 } + end + post 'is_with_max' do + end + end + end + + describe '/with_min_max' do + context 'when length is within limits' do + it do + post '/with_min_max', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is exceeded' do + it do + post '/with_min_max', list: [1, 2, 3, 4, 5] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 2 and 3') + end + end + + context 'when length is less than minimum' do + it do + post '/with_min_max', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 2 and 3') + end + end + end + + describe '/with_max_only' do + context 'when length is less than limits' do + it do + post '/with_max_only', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is exceeded' do + it do + post '/with_max_only', list: [1, 2, 3, 4, 5] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 3') + end + end + end + + describe '/with_min_only' do + context 'when length is greater than limit' do + it do + post '/with_min_only', list: [1, 2] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is less than limit' do + it do + post '/with_min_only', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length greater than or equal to 2') + end + end + end + + describe '/zero_min' do + context 'when length is equal to the limit' do + it do + post '/zero_min', list: '[]' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is greater than limit' do + it do + post '/zero_min', list: [{ key: 'value' }] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + end + + describe '/zero_max' do + context 'when length is within the limit' do + it do + post '/zero_max', list: '[]' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length is greater than limit' do + it do + post '/zero_max', list: [{ key: 'value' }] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 0') + end + end + end + + describe '/type_is_not_array' do + context 'does not raise an error' do + it do + expect do + post 'type_is_not_array', list: 12 + end.not_to raise_error + end + end + end + + describe '/type_supports_length' do + context 'when length is within limits' do + it do + post 'type_supports_length', list: { key: 'value' } + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length exceeds the limit' do + it do + post 'type_supports_length', list: { key: 'value', key1: 'value', key3: 'value', key4: 'value' } + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length less than or equal to 3') + end + end + end + + describe '/negative_min' do + context 'when min is negative' do + it do + expect { post 'negative_min', list: [12] }.to raise_error(ArgumentError, 'min must be an integer greater than or equal to zero') + end + end + end + + describe '/negative_max' do + context 'it raises an error' do + it do + expect { post 'negative_max', list: [12] }.to raise_error(ArgumentError, 'max must be an integer greater than or equal to zero') + end + end + end + + describe '/float_min' do + context 'when min is not an integer' do + it do + expect { post 'float_min', list: [12] }.to raise_error(ArgumentError, 'min must be an integer greater than or equal to zero') + end + end + end + + describe '/float_max' do + context 'when max is not an integer' do + it do + expect { post 'float_max', list: [12] }.to raise_error(ArgumentError, 'max must be an integer greater than or equal to zero') + end + end + end + + describe '/min_greater_than_max' do + context 'raises an error' do + it do + expect { post 'min_greater_than_max', list: [1, 2] }.to raise_error(ArgumentError, 'min 15 cannot be greater than max 3') + end + end + end + + describe '/min_equal_to_max' do + context 'when array meets expectations' do + it do + post 'min_equal_to_max', list: [1, 2, 3] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when array is less than min' do + it do + post 'min_equal_to_max', list: [1, 2] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 3 and 3') + end + end + + context 'when array is greater than max' do + it do + post 'min_equal_to_max', list: [1, 2, 3, 4] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list is expected to have length within 3 and 3') + end + end + end + + describe '/custom-message' do + context 'is within limits' do + it do + post '/custom-message', list: [1, 2, 3] + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'is outside limit' do + it do + post '/custom-message', list: [1] + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('list not match') + end + end + end + + describe '/is' do + context 'when length is exact' do + it do + post 'is', code: 'ZZ' + expect(last_response.status).to eq(201) + expect(last_response.body).to eq('') + end + end + + context 'when length exceeds the limit' do + it do + post 'is', code: 'aze' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('code is expected to have length exactly equal to 2') + end + end + + context 'when length is less than the limit' do + it do + post 'is', code: 'a' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('code is expected to have length exactly equal to 2') + end + end + + context 'when length is zero' do + it do + post 'is', code: '' + expect(last_response.status).to eq(400) + expect(last_response.body).to eq('code is expected to have length exactly equal to 2') + end + end + end + + describe '/negative_is' do + context 'when `is` is negative' do + it do + expect { post 'negative_is', code: 'ZZ' }.to raise_error(ArgumentError, 'is must be an integer greater than zero') + end + end + end + + describe '/is_with_max' do + context 'when `is` is combined with max' do + it do + expect { post 'is_with_max', code: 'ZZ' }.to raise_error(ArgumentError, 'is cannot be combined with min or max') + end + end + end +end diff --git a/spec/grape/validations/validators/mutual_exclusion_spec.rb b/spec/grape/validations/validators/mutual_exclusion_validator_spec.rb similarity index 65% rename from spec/grape/validations/validators/mutual_exclusion_spec.rb rename to spec/grape/validations/validators/mutual_exclusion_validator_spec.rb index ac1b46989..a36a26c63 100644 --- a/spec/grape/validations/validators/mutual_exclusion_spec.rb +++ b/spec/grape/validations/validators/mutual_exclusion_validator_spec.rb @@ -1,96 +1,88 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::MutualExclusionValidator do - describe '#validate!' do - subject(:validate) { post path, params } - - module ValidationsSpec - module MutualExclusionValidatorSpec - class API < Grape::API - rescue_from Grape::Exceptions::ValidationErrors do |e| - error!(e.errors.transform_keys! { |key| key.join(',') }, 400) - end +describe Grape::Validations::Validators::MutualExclusionValidator do + let_it_be(:app) do + Class.new(Grape::API) do + rescue_from Grape::Exceptions::ValidationErrors do |e| + error!(e.errors.transform_keys! { |key| key.join(',') }, 400) + end - params do - optional :beer - optional :wine - optional :grapefruit - mutually_exclusive :beer, :wine, :grapefruit - end - post do - end + params do + optional :beer + optional :wine + optional :grapefruit + mutually_exclusive :beer, :wine, :grapefruit + end + post do + end - params do - optional :beer - optional :wine - optional :grapefruit - optional :other - mutually_exclusive :beer, :wine, :grapefruit - end - post 'mixed-params' do - end + params do + optional :beer + optional :wine + optional :grapefruit + optional :other + mutually_exclusive :beer, :wine, :grapefruit + end + post 'mixed-params' do + end - params do - optional :beer - optional :wine - optional :grapefruit - mutually_exclusive :beer, :wine, :grapefruit, message: 'you should not mix beer and wine' - end - post '/custom-message' do - end + params do + optional :beer + optional :wine + optional :grapefruit + mutually_exclusive :beer, :wine, :grapefruit, message: 'you should not mix beer and wine' + end + post '/custom-message' do + end - params do - requires :item, type: Hash do - optional :beer - optional :wine - optional :grapefruit - mutually_exclusive :beer, :wine, :grapefruit - end - end - post '/nested-hash' do - end + params do + requires :item, type: Hash do + optional :beer + optional :wine + optional :grapefruit + mutually_exclusive :beer, :wine, :grapefruit + end + end + post '/nested-hash' do + end - params do - optional :item, type: Hash do - optional :beer - optional :wine - optional :grapefruit - mutually_exclusive :beer, :wine, :grapefruit - end - end - post '/nested-optional-hash' do - end + params do + optional :item, type: Hash do + optional :beer + optional :wine + optional :grapefruit + mutually_exclusive :beer, :wine, :grapefruit + end + end + post '/nested-optional-hash' do + end - params do - requires :items, type: Array do - optional :beer - optional :wine - optional :grapefruit - mutually_exclusive :beer, :wine, :grapefruit - end - end - post '/nested-array' do - end + params do + requires :items, type: Array do + optional :beer + optional :wine + optional :grapefruit + mutually_exclusive :beer, :wine, :grapefruit + end + end + post '/nested-array' do + end - params do - requires :items, type: Array do - requires :nested_items, type: Array do - optional :beer, :wine, :grapefruit, type: Boolean - mutually_exclusive :beer, :wine, :grapefruit - end - end - end - post '/deeply-nested-array' do + params do + requires :items, type: Array do + requires :nested_items, type: Array do + optional :beer, :wine, :grapefruit, type: Grape::API::Boolean + mutually_exclusive :beer, :wine, :grapefruit end end end + post '/deeply-nested-array' do + end end + end - def app - ValidationsSpec::MutualExclusionValidatorSpec::API - end + describe '#validate!' do + subject(:validate) { post path, params } context 'when all mutually exclusive params are present' do let(:path) { '/' } diff --git a/spec/grape/validations/validators/presence_spec.rb b/spec/grape/validations/validators/presence_validator_spec.rb similarity index 93% rename from spec/grape/validations/validators/presence_spec.rb rename to spec/grape/validations/validators/presence_validator_spec.rb index 5eaabe72a..3dc2dc8a8 100644 --- a/spec/grape/validations/validators/presence_spec.rb +++ b/spec/grape/validations/validators/presence_validator_spec.rb @@ -1,13 +1,12 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::PresenceValidator do +describe Grape::Validations::Validators::PresenceValidator do subject do Class.new(Grape::API) do format :json end end + def app subject end @@ -20,6 +19,7 @@ def app end end end + it 'does not validate for any params' do get '/bacons' expect(last_response.status).to eq(200) @@ -38,15 +38,18 @@ def app end end end + it 'requires when missing' do get '/requires' expect(last_response.status).to eq(400) expect(last_response.body).to eq('{"error":"email is required, email has no value"}') end + it 'requires when empty' do get '/requires', email: '' expect(last_response.body).to eq('{"error":"email has no value, email format is invalid"}') end + it 'valid when set' do get '/requires', email: 'bob@example.com' expect(last_response.status).to eq(200) @@ -64,20 +67,19 @@ def app { ret: params[:id] } end end + it 'validates id' do post '/' - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request expect(last_response.body).to eq('{"error":"id is missing"}') - io = StringIO.new('{"id" : "a56b"}') - post '/', {}, 'rack.input' => io, 'CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => io.length + post '/', { id: 'a56b' }.to_json, 'CONTENT_TYPE' => 'application/json' expect(last_response.body).to eq('{"error":"id is invalid"}') - expect(last_response.status).to eq(400) + expect(last_response).to be_bad_request - io = StringIO.new('{"id" : 56}') - post '/', {}, 'rack.input' => io, 'CONTENT_TYPE' => 'application/json', 'CONTENT_LENGTH' => io.length + post '/', { id: 56 }.to_json, 'CONTENT_TYPE' => 'application/json' expect(last_response.body).to eq('{"ret":56}') - expect(last_response.status).to eq(201) + expect(last_response).to be_created end end @@ -90,16 +92,19 @@ def app 'Hello' end end + it 'requires when missing' do get '/' expect(last_response.status).to eq(400) expect(last_response.body).to eq('{"error":"email is missing, email is empty"}') end + it 'requires when empty' do get '/', email: '' expect(last_response.status).to eq(400) expect(last_response.body).to eq('{"error":"email is empty, email is invalid"}') end + it 'valid when set' do get '/', email: 'bob@example.com' expect(last_response.status).to eq(200) @@ -124,6 +129,7 @@ def app 'Hello' end end + it 'validates for all defined params' do get '/single-requires' expect(last_response.status).to eq(400) @@ -144,6 +150,7 @@ def app 'Hello' end end + it 'validates name, company' do get '/' expect(last_response.status).to eq(400) @@ -171,6 +178,7 @@ def app 'Nested' end end + it 'validates nested parameters' do get '/nested' expect(last_response.status).to eq(400) @@ -203,6 +211,7 @@ def app 'Nested triple' end end + it 'validates triple nested parameters' do get '/nested_triple' expect(last_response.status).to eq(400) @@ -252,6 +261,7 @@ def app 'Hello optional' end end + it 'works with required' do get '/required' expect(last_response.status).to eq(400) @@ -261,6 +271,7 @@ def app expect(last_response.status).to eq(200) expect(last_response.body).to eq('Hello required'.to_json) end + it 'works with optional' do get '/optional' expect(last_response.status).to eq(200) @@ -274,7 +285,7 @@ def app context 'with a custom type' do it 'does not validate their type when it is missing' do - class CustomType + custom_type = Class.new do def self.parse(value) return if value.blank? @@ -283,7 +294,7 @@ def self.parse(value) end subject.params do - requires :custom, type: CustomType + requires :custom, type: custom_type end subject.get '/custom' do 'custom' diff --git a/spec/grape/validations/validators/regexp_spec.rb b/spec/grape/validations/validators/regexp_validator_spec.rb similarity index 78% rename from spec/grape/validations/validators/regexp_spec.rb rename to spec/grape/validations/validators/regexp_validator_spec.rb index b4ffc99df..c12e8a60a 100644 --- a/spec/grape/validations/validators/regexp_spec.rb +++ b/spec/grape/validations/validators/regexp_validator_spec.rb @@ -1,52 +1,63 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::RegexpValidator do - module ValidationsSpec - module RegexpValidatorSpec - class API < Grape::API - default_format :json - - resources :custom_message do - params do - requires :name, regexp: { value: /^[a-z]+$/, message: 'format is invalid' } - end - get do - end - - params do - requires :names, type: { value: Array[String], message: 'can\'t be nil' }, regexp: { value: /^[a-z]+$/, message: 'format is invalid' } - end - get 'regexp_with_array' do - end - end +describe Grape::Validations::Validators::RegexpValidator do + let_it_be(:app) do + Class.new(Grape::API) do + default_format :json + resources :custom_message do params do - requires :name, regexp: /^[a-z]+$/ + requires :name, regexp: { value: /^[a-z]+$/, message: 'format is invalid' } end get do end params do - requires :names, type: Array[String], regexp: /^[a-z]+$/ + requires :names, type: { value: Array[String], message: 'can\'t be nil' }, regexp: { value: /^[a-z]+$/, message: 'format is invalid' } end get 'regexp_with_array' do end + end - params do - requires :people, type: Hash do - requires :names, type: Array[String], regexp: /^[a-z]+$/ - end - end - get 'nested_regexp_with_array' do + params do + requires :name, regexp: /^[a-z]+$/ + end + get do + end + + params do + requires :names, type: Array[String], regexp: /^[a-z]+$/ + end + get 'regexp_with_array' do + end + + params do + requires :people, type: Hash do + requires :names, type: Array[String], regexp: /^[a-z]+$/ end end + get 'nested_regexp_with_array' do + end end end - def app - ValidationsSpec::RegexpValidatorSpec::API + describe '#bad encoding' do + let(:app) do + Class.new(Grape::API) do + default_format :json + + params do + requires :name, regexp: { value: /^[a-z]+$/ } + end + get '/bad_encoding' + end + end + + context 'when value as bad encoding' do + it 'does not raise an error' do + expect { get '/bad_encoding', name: "Hello \x80" }.not_to raise_error + end + end end context 'custom validation message' do diff --git a/spec/grape/validations/validators/same_as_spec.rb b/spec/grape/validations/validators/same_as_validator_spec.rb similarity index 66% rename from spec/grape/validations/validators/same_as_spec.rb rename to spec/grape/validations/validators/same_as_validator_spec.rb index da4c945c0..cc1ac984e 100644 --- a/spec/grape/validations/validators/same_as_spec.rb +++ b/spec/grape/validations/validators/same_as_validator_spec.rb @@ -1,32 +1,24 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::SameAsValidator do - module ValidationsSpec - module SameAsValidatorSpec - class API < Grape::API - params do - requires :password - requires :password_confirmation, same_as: :password - end - post do - end +describe Grape::Validations::Validators::SameAsValidator do + let_it_be(:app) do + Class.new(Grape::API) do + params do + requires :password + requires :password_confirmation, same_as: :password + end + post do + end - params do - requires :password - requires :password_confirmation, same_as: { value: :password, message: 'not match' } - end - post '/custom-message' do - end + params do + requires :password + requires :password_confirmation, same_as: { value: :password, message: 'not match' } + end + post '/custom-message' do end end end - def app - ValidationsSpec::SameAsValidatorSpec::API - end - describe '/' do context 'is the same' do it do diff --git a/spec/grape/validations/validators/values_spec.rb b/spec/grape/validations/validators/values_validator_spec.rb similarity index 64% rename from spec/grape/validations/validators/values_spec.rb rename to spec/grape/validations/validators/values_validator_spec.rb index 491b32834..7ef60a58e 100644 --- a/spec/grape/validations/validators/values_spec.rb +++ b/spec/grape/validations/validators/values_validator_spec.rb @@ -1,16 +1,12 @@ # frozen_string_literal: true -require 'spec_helper' - -describe Grape::Validations::ValuesValidator do - module ValidationsSpec - class ValuesModel - DEFAULT_VALUES = ['valid-type1', 'valid-type2', 'valid-type3'].freeze - DEFAULT_EXCEPTS = ['invalid-type1', 'invalid-type2', 'invalid-type3'].freeze +describe Grape::Validations::Validators::ValuesValidator do + let(:values_model) do + Class.new do class << self def values @values ||= [] - [DEFAULT_VALUES + @values].flatten.uniq + [default_values + @values].flatten.uniq end def add_value(value) @@ -20,221 +16,284 @@ def add_value(value) def excepts @excepts ||= [] - [DEFAULT_EXCEPTS + @excepts].flatten.uniq + [default_excepts + @excepts].flatten.uniq end def add_except(except) @excepts ||= [] @excepts << except end - end - end - module ValuesValidatorSpec - class API < Grape::API - default_format :json - - resources :custom_message do - params do - requires :type, values: { value: ValuesModel.values, message: 'value does not include in values' } - end - get '/' do - { type: params[:type] } - end - - params do - optional :type, values: { value: -> { ValuesModel.values }, message: 'value does not include in values' }, default: 'valid-type2' - end - get '/lambda' do - { type: params[:type] } - end - - params do - requires :type, values: { except: ValuesModel.excepts, except_message: 'value is on exclusions list', message: 'default exclude message' } - end - get '/exclude/exclude_message' - - params do - requires :type, values: { except: -> { ValuesModel.excepts }, except_message: 'value is on exclusions list' } - end - get '/exclude/lambda/exclude_message' - - params do - requires :type, values: { except: ValuesModel.excepts, message: 'default exclude message' } - end - get '/exclude/fallback_message' + def include?(value) + values.include?(value) end - params do - requires :type, values: ValuesModel.values - end - get '/' do - { type: params[:type] } + def even?(value) + value.to_i.even? end - params do - requires :type, values: [] - end - get '/empty' + private - params do - optional :type, values: { value: ValuesModel.values }, default: 'valid-type2' - end - get '/default/hash/valid' do - { type: params[:type] } + def default_values + %w[valid-type1 valid-type2 valid-type3].freeze end - params do - optional :type, values: ValuesModel.values, default: 'valid-type2' - end - get '/default/valid' do - { type: params[:type] } + def default_excepts + %w[invalid-type1 invalid-type2 invalid-type3].freeze end + end + end + end + + let(:app) do + Class.new(Grape::API) do + default_format :json + resources :custom_message do params do - optional :type, values: { except: ValuesModel.excepts }, default: 'valid-type2' + requires :type, values: { value: ValuesModel.values, message: 'value does not include in values' } end - get '/default/except' do + get '/' do { type: params[:type] } end params do - optional :type, values: -> { ValuesModel.values }, default: 'valid-type2' + optional :type, values: { value: -> { ValuesModel.values }, message: 'value does not include in values' }, default: 'valid-type2' end get '/lambda' do { type: params[:type] } end params do - requires :type, values: ->(v) { ValuesModel.values.include? v } - end - get '/lambda_val' do - { type: params[:type] } + requires :type, except_values: { value: ValuesModel.excepts, message: 'value is on exclusions list' }, default: 'default exclude message' end + get '/exclude/exclude_message' params do - requires :number, type: Integer, values: ->(v) { v > 0 } - end - get '/lambda_int_val' do - { number: params[:number] } + requires :type, except_values: { value: -> { ValuesModel.excepts }, message: 'value is on exclusions list' } end + get '/exclude/lambda/exclude_message' params do - requires :type, values: -> { [] } + requires :type, except_values: { value: ValuesModel.excepts, message: 'default exclude message' } end - get '/empty_lambda' + get '/exclude/fallback_message' + end - params do - optional :type, values: ValuesModel.values, default: -> { ValuesModel.values.sample } - end - get '/default_lambda' do - { type: params[:type] } - end + params do + requires :type, values: ValuesModel.values + end + get '/' do + { type: params[:type] } + end - params do - optional :type, values: -> { ValuesModel.values }, default: -> { ValuesModel.values.sample } - end - get '/default_and_values_lambda' do - { type: params[:type] } - end + params do + requires :type, values: [] + end + get '/empty' - params do - optional :type, type: Boolean, desc: 'A boolean', values: [true] - end - get '/values/optional_boolean' do - { type: params[:type] } - end + params do + optional :type, values: { value: ValuesModel.values }, default: 'valid-type2' + end + get '/default/hash/valid' do + { type: params[:type] } + end - params do - requires :type, type: Integer, desc: 'An integer', values: [10, 11], default: 10 - end - get '/values/coercion' do - { type: params[:type] } - end + params do + optional :type, values: ValuesModel.values, default: 'valid-type2' + end + get '/default/valid' do + { type: params[:type] } + end - params do - requires :type, type: Array[Integer], desc: 'An integer', values: [10, 11], default: 10 - end - get '/values/array_coercion' do - { type: params[:type] } - end + params do + optional :type, except_values: ValuesModel.excepts, default: 'valid-type2' + end + get '/default/except' do + { type: params[:type] } + end - params do - optional :optional, type: Array do - requires :type, values: %w[a b] - end - end - get '/optional_with_required_values' + params do + optional :type, values: -> { ValuesModel.values }, default: 'valid-type2' + end + get '/lambda' do + { type: params[:type] } + end - params do - requires :type, values: { except: ValuesModel.excepts } - end - get '/except/exclusive' do - { type: params[:type] } - end + params do + optional :type, type: Integer, values: 1.. + end + get '/endless' do + { type: params[:type] } + end - params do - requires :type, type: String, values: { except: ValuesModel.excepts } - end - get '/except/exclusive/type' do - { type: params[:type] } - end + params do + requires :type, values: ->(v) { ValuesModel.include? v } + end + get '/lambda_val' do + { type: params[:type] } + end - params do - requires :type, values: { except: -> { ValuesModel.excepts } } - end - get '/except/exclusive/lambda' do - { type: params[:type] } - end + params do + requires :number, type: Integer, values: ->(v) { v > 0 } + end + get '/lambda_int_val' do + { number: params[:number] } + end - params do - requires :type, type: String, values: { except: -> { ValuesModel.excepts } } - end - get '/except/exclusive/lambda/type' do - { type: params[:type] } - end + params do + requires :type, values: -> { [] } + end + get '/empty_lambda' - params do - requires :type, type: Integer, values: { except: -> { [3, 4, 5] } } - end - get '/except/exclusive/lambda/coercion' do - { type: params[:type] } - end + params do + optional :type, values: ValuesModel.values, default: -> { ValuesModel.values.sample } + end + get '/default_lambda' do + { type: params[:type] } + end - params do - requires :type, type: Integer, values: { value: 1..5, except: [3] } - end - get '/mixed/value/except' do - { type: params[:type] } - end + params do + optional :type, values: -> { ValuesModel.values }, default: -> { ValuesModel.values.sample } + end + get '/default_and_values_lambda' do + { type: params[:type] } + end - params do - optional :optional, type: Array[String], values: %w[a b c] - end - put '/optional_with_array_of_string_values' + params do + optional :type, type: Grape::API::Boolean, desc: 'A boolean', values: [true] + end + get '/values/optional_boolean' do + { type: params[:type] } + end - params do - requires :type, values: { proc: ->(v) { ValuesModel.values.include? v } } - end - get '/proc' do - { type: params[:type] } + params do + requires :type, type: Integer, desc: 'An integer', values: [10, 11], default: 10 + end + get '/values/coercion' do + { type: params[:type] } + end + + params do + requires :type, type: Array[Integer], desc: 'An integer', values: [10, 11], default: 10 + end + get '/values/array_coercion' do + { type: params[:type] } + end + + params do + optional :optional, type: Array do + requires :type, values: %w[a b] end + end + get '/optional_with_required_values' - params do - requires :type, values: { proc: ->(v) { ValuesModel.values.include? v }, message: 'failed check' } + params do + requires :type, except_values: ValuesModel.excepts + end + get '/except/exclusive' do + { type: params[:type] } + end + + params do + requires :type, type: String, except_values: ValuesModel.excepts + end + get '/except/exclusive/type' do + { type: params[:type] } + end + + params do + requires :type, except_values: ValuesModel.excepts + end + get '/except/exclusive/lambda' do + { type: params[:type] } + end + + params do + requires :type, type: String, except_values: -> { ValuesModel.excepts } + end + get '/except/exclusive/lambda/type' do + { type: params[:type] } + end + + params do + requires :type, type: Integer, except_values: -> { [3, 4, 5] } + end + get '/except/exclusive/lambda/coercion' do + { type: params[:type] } + end + + params do + requires :type, type: Integer, values: 1..5, except_values: [3] + end + get '/mixed/value/except' do + { type: params[:type] } + end + + params do + optional :optional, type: Array[String], values: %w[a b c] + end + put '/optional_with_array_of_string_values' + + params do + requires :type, values: ->(v) { ValuesModel.include? v } + end + get '/proc' do + { type: params[:type] } + end + + params do + requires :type, values: { value: ->(v) { ValuesModel.include? v }, message: 'failed check' } + end + get '/proc/message' + + params do + requires :number, values: { value: ->(v) { ValuesModel.even? v }, message: 'must be even' } + end + get '/proc/custom_message' do + { message: 'success' } + end + + params do + requires :input_one, :input_two, values: { value: ->(v1, v2) { v1 + v2 > 10 } } + end + get '/proc/arity2' + + params do + optional :name, type: String, values: %w[a b], allow_blank: true + end + get '/allow_blank' + + params do + with(type: String) do + requires :type, values: ValuesModel.values end - get '/proc/message' + end + get 'values_wrapped_by_with_block' + end + end + + before do + stub_const('ValuesModel', values_model) + end + + describe '#bad encoding' do + let(:app) do + Class.new(Grape::API) do + default_format :json params do - optional :name, type: String, values: %w[a b], allow_blank: true + requires :type, type: String, values: %w[a b] end - get '/allow_blank' + get '/bad_encoding' end end - end - def app - ValidationsSpec::ValuesValidatorSpec::API + context 'when value as bad encoding' do + it 'does not raise an error' do + expect { get '/bad_encoding', type: "Hello \x80" }.not_to raise_error + end + end end context 'with a custom validation message' do @@ -251,7 +310,7 @@ def app end it 'validates against values in a proc' do - ValidationsSpec::ValuesModel.add_value('valid-type4') + ValuesModel.add_value('valid-type4') get('/custom_message/lambda', type: 'valid-type4') expect(last_response.status).to eq 200 @@ -350,15 +409,15 @@ def app end it 'does not validate updated values without proc' do - ValidationsSpec::ValuesModel.add_value('valid-type4') - + app # Instantiate with the existing values. + ValuesModel.add_value('valid-type4') get('/', type: 'valid-type4') expect(last_response.status).to eq 400 expect(last_response.body).to eq({ error: 'type does not have a valid value' }.to_json) end it 'validates against values in a proc' do - ValidationsSpec::ValuesModel.add_value('valid-type4') + ValuesModel.add_value('valid-type4') get('/lambda', type: 'valid-type4') expect(last_response.status).to eq 200 @@ -371,6 +430,18 @@ def app expect(last_response.body).to eq({ error: 'type does not have a valid value' }.to_json) end + it 'validates against values in an endless range' do + get('/endless', type: 10) + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ type: 10 }.to_json) + end + + it 'does not allow an invalid value for a parameter using an endless range' do + get('/endless', type: 0) + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'type does not have a valid value' }.to_json) + end + it 'does not allow non-numeric string value for int value using lambda' do get('/lambda_int_val', number: 'foo') expect(last_response.status).to eq 400 @@ -420,21 +491,21 @@ def app it 'raises IncompatibleOptionValues on an invalid default value from proc' do subject = Class.new(Grape::API) expect do - subject.params { optional :type, values: ['valid-type1', 'valid-type2', 'valid-type3'], default: ValidationsSpec::ValuesModel.values.sample + '_invalid' } + subject.params { optional :type, values: %w[valid-type1 valid-type2 valid-type3], default: "#{ValuesModel.values.sample}_invalid" } end.to raise_error Grape::Exceptions::IncompatibleOptionValues end it 'raises IncompatibleOptionValues on an invalid default value' do subject = Class.new(Grape::API) expect do - subject.params { optional :type, values: ['valid-type1', 'valid-type2', 'valid-type3'], default: 'invalid-type' } + subject.params { optional :type, values: %w[valid-type1 valid-type2 valid-type3], default: 'invalid-type' } end.to raise_error Grape::Exceptions::IncompatibleOptionValues end it 'raises IncompatibleOptionValues when type is incompatible with values array' do subject = Class.new(Grape::API) expect do - subject.params { optional :type, values: ['valid-type1', 'valid-type2', 'valid-type3'], type: Symbol } + subject.params { optional :type, values: %w[valid-type1 valid-type2 valid-type3], type: Symbol } end.to raise_error Grape::Exceptions::IncompatibleOptionValues end @@ -475,7 +546,7 @@ def app it 'raises IncompatibleOptionValues when except contains a value that is not a kind of the type' do subject = Class.new(Grape::API) expect do - subject.params { requires :type, values: { except: [10.5, 11] }, type: Integer } + subject.params { requires :type, except_values: [10.5, 11], type: Integer } end.to raise_error Grape::Exceptions::IncompatibleOptionValues end @@ -648,9 +719,9 @@ def app end it 'accepts multiple valid values' do - get '/proc', type: ['valid-type1', 'valid-type3'] + get '/proc', type: %w[valid-type1 valid-type3] expect(last_response.status).to eq 200 - expect(last_response.body).to eq({ type: ['valid-type1', 'valid-type3'] }.to_json) + expect(last_response.body).to eq({ type: %w[valid-type1 valid-type3] }.to_json) end it 'rejects a single invalid value' do @@ -660,7 +731,7 @@ def app end it 'rejects an invalid value among valid ones' do - get '/proc', type: ['valid-type1', 'invalid-type1', 'valid-type3'] + get '/proc', type: %w[valid-type1 invalid-type1 valid-type3] expect(last_response.status).to eq 400 expect(last_response.body).to eq({ error: 'type does not have a valid value' }.to_json) end @@ -670,5 +741,35 @@ def app expect(last_response.status).to eq 400 expect(last_response.body).to eq({ error: 'type failed check' }.to_json) end + + context 'when proc has an arity of 1' do + it 'accepts a valid value' do + get '/proc/custom_message', number: 4 + expect(last_response.status).to eq 200 + expect(last_response.body).to eq({ message: 'success' }.to_json) + end + + it 'rejects an invalid value' do + get '/proc/custom_message', number: 5 + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'number must be even' }.to_json) + end + end + + context 'when arity is > 1' do + it 'returns an error status code' do + get '/proc/arity2', input_one: 2, input_two: 3 + expect(last_response.status).to eq 400 + end + end + end + + context 'when wrapped by with block' do + it 'rejects an invalid value' do + get 'values_wrapped_by_with_block' + + expect(last_response.status).to eq 400 + expect(last_response.body).to eq({ error: 'type is missing, type does not have a valid value' }.to_json) + end end end diff --git a/spec/grape/validations_spec.rb b/spec/grape/validations_spec.rb index 232c23534..5f983d2aa 100644 --- a/spec/grape/validations_spec.rb +++ b/spec/grape/validations_spec.rb @@ -1,17 +1,10 @@ # frozen_string_literal: true -require 'spec_helper' - describe Grape::Validations do subject { Class.new(Grape::API) } - def app - subject - end - - def declared_params - subject.namespace_stackable(:declared_params).flatten - end + let(:app) { subject } + let(:declared_params) { subject.namespace_stackable(:declared_params).flatten } describe 'params' do context 'optional' do @@ -45,7 +38,7 @@ def declared_params subject.params do optional :some_param end - expect(declared_params).to eq([:some_param]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([:some_param]) end end @@ -65,7 +58,7 @@ def define_optional_using it 'adds entity documentation to declared params' do define_optional_using - expect(declared_params).to eq(%i[field_a field_b]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq(%i[field_a field_b]) end it 'works when field_a and field_b are not present' do @@ -112,7 +105,7 @@ def define_optional_using subject.params do requires :some_param end - expect(declared_params).to eq([:some_param]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([:some_param]) end it 'works when required field is present but nil' do @@ -181,11 +174,12 @@ def define_optional_using context 'requires :all using Grape::Entity documentation' do def define_requires_all documentation = { - required_field: { type: String }, - optional_field: { type: String } + required_field: { type: String, required: true, param_type: 'query' }, + optional_field: { type: String }, + optional_array_field: { type: Array[String], is_array: true } } subject.params do - requires :all, except: :optional_field, using: documentation + requires :all, except: %i[optional_field optional_array_field], using: documentation end end before do @@ -197,7 +191,7 @@ def define_requires_all it 'adds entity documentation to declared params' do define_requires_all - expect(declared_params).to eq(%i[required_field optional_field]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq(%i[required_field optional_field optional_array_field]) end it 'errors when required_field is not present' do @@ -216,8 +210,8 @@ def define_requires_all context 'requires :none using Grape::Entity documentation' do def define_requires_none documentation = { - required_field: { type: String }, - optional_field: { type: String } + required_field: { type: String, example: 'Foo' }, + optional_field: { type: Integer, format: 'int64' } } subject.params do requires :none, except: :required_field, using: documentation @@ -232,7 +226,7 @@ def define_requires_none it 'adds entity documentation to declared params' do define_requires_none - expect(declared_params).to eq(%i[required_field optional_field]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq(%i[required_field optional_field]) end it 'errors when required_field is not present' do @@ -262,7 +256,7 @@ def define_requires_all it 'adds only the entity documentation to declared params, nothing more' do define_requires_all - expect(declared_params).to eq(%i[required_field optional_field]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq(%i[required_field optional_field]) end end @@ -328,7 +322,7 @@ def define_requires_none requires :key end end - expect(declared_params).to eq([items: [:key]]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([items: [:key]]) end end @@ -400,7 +394,7 @@ def define_requires_none requires :key end end - expect(declared_params).to eq([items: [:key]]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([items: [:key]]) end end @@ -463,7 +457,7 @@ def define_requires_none requires :key end end - expect(declared_params).to eq([items: [:key]]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([items: [:key]]) end end @@ -489,18 +483,19 @@ def define_requires_none end context 'custom validator for a Hash' do - module ValuesSpec - module DateRangeValidations - class DateRangeValidator < Grape::Validations::Base - def validate_param!(attr_name, params) - return if params[attr_name][:from] <= params[attr_name][:to] - raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: "'from' must be lower or equal to 'to'") - end + let(:date_range_validator) do + Class.new(Grape::Validations::Validators::Base) do + def validate_param!(attr_name, params) + return if params[attr_name][:from] <= params[attr_name][:to] + + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: "'from' must be lower or equal to 'to'") end end end before do + stub_const('DateRangeValidator', date_range_validator) + described_class.register(DateRangeValidator) subject.params do optional :date_range, date_range: true, type: Hash do requires :from, type: Integer @@ -521,6 +516,10 @@ def validate_param!(attr_name, params) end end + after do + described_class.deregister(:date_range) + end + context 'which is optional' do it "doesn't throw an error if the validation passes" do get '/optional', date_range: { from: 1, to: 2 } @@ -817,7 +816,7 @@ def validate_param!(attr_name, params) requires :key end end - expect(declared_params).to eq([items: [:key]]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([items: [:key]]) end end @@ -881,7 +880,284 @@ def validate_param!(attr_name, params) requires(:required_subitems, type: Array) { requires :value } end end - expect(declared_params).to eq([items: [:key, { optional_subitems: [:value] }, { required_subitems: [:value] }]]) + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq([items: [:key, { optional_subitems: [:value] }, { required_subitems: [:value] }]]) + end + + context <<~DESC do + Issue occurs whenever: + * param structure with at least three levels + * 1st level item is a required Array that has >1 entry with an optional item present and >1 entry with an optional item missing#{' '} + * 2nd level is an optional Array or Hash#{' '} + * 3rd level is a required item (can be any type) + * additional levels do not effect the issue from occuring + DESC + + it 'example based off actual real world use case' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Array do + requires :batches, type: Array do + requires :batch_no, type: String + end + end + end + end + + subject.get '/validate_required_arrays_under_optional_arrays' do + 'validate_required_arrays_under_optional_arrays works!' + end + + data = { + orders: [ + { id: 77, drugs: [{ batches: [{ batch_no: 'A1234567' }] }] }, + { id: 70 } + ] + } + + get '/validate_required_arrays_under_optional_arrays', data + expect(last_response.body).to eq('validate_required_arrays_under_optional_arrays works!') + expect(last_response.status).to eq(200) + end + + it 'simplest example using Array -> Array -> Hash -> String' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Array do + requires :batch_no, type: String + end + end + end + + subject.get '/validate_required_arrays_under_optional_arrays' do + 'validate_required_arrays_under_optional_arrays works!' + end + + data = { + orders: [ + { id: 77, drugs: [{ batch_no: 'A1234567' }] }, + { id: 70 } + ] + } + + get '/validate_required_arrays_under_optional_arrays', data + expect(last_response.body).to eq('validate_required_arrays_under_optional_arrays works!') + expect(last_response.status).to eq(200) + end + + it 'simplest example using Array -> Hash -> String' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Hash do + requires :batch_no, type: String + end + end + end + + subject.get '/validate_required_arrays_under_optional_arrays' do + 'validate_required_arrays_under_optional_arrays works!' + end + + data = { + orders: [ + { id: 77, drugs: { batch_no: 'A1234567' } }, + { id: 70 } + ] + } + + get '/validate_required_arrays_under_optional_arrays', data + expect(last_response.body).to eq('validate_required_arrays_under_optional_arrays works!') + expect(last_response.status).to eq(200) + end + + it 'correctly indexes invalida data' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Array do + requires :batch_no, type: String + requires :quantity, type: Integer + end + end + end + + subject.get '/correctly_indexes' do + 'correctly_indexes works!' + end + + data = { + orders: [ + { id: 70 }, + { id: 77, drugs: [{ batch_no: 'A1234567', quantity: 12 }, { batch_no: 'B222222' }] } + ] + } + + get '/correctly_indexes', data + expect(last_response.body).to eq('orders[1][drugs][1][quantity] is missing') + expect(last_response.status).to eq(400) + end + + context 'multiple levels of optional and requires settings' do + before do + subject.params do + requires :top, type: Array do + requires :top_id, type: Integer, allow_blank: false + optional :middle_1, type: Array do + requires :middle_1_id, type: Integer, allow_blank: false + optional :middle_2, type: Array do + requires :middle_2_id, type: String, allow_blank: false + optional :bottom, type: Array do + requires :bottom_id, type: Integer, allow_blank: false + end + end + end + end + end + + subject.get '/multi_level' do + 'multi_level works!' + end + end + + it 'with valid data' do + data = { + top: [ + { top_id: 1, middle_1: [ + { middle_1_id: 11 }, { middle_1_id: 12, middle_2: [ + { middle_2_id: 121 }, { middle_2_id: 122, bottom: [{ bottom_id: 1221 }] } + ] } + ] }, + { top_id: 2, middle_1: [ + { middle_1_id: 21 }, { middle_1_id: 22, middle_2: [ + { middle_2_id: 221 } + ] } + ] }, + { top_id: 3, middle_1: [ + { middle_1_id: 31 }, { middle_1_id: 32 } + ] }, + { top_id: 4 } + ] + } + + get '/multi_level', data + expect(last_response.body).to eq('multi_level works!') + expect(last_response.status).to eq(200) + end + + it 'with invalid data' do + data = { + top: [ + { top_id: 1, middle_1: [ + { middle_1_id: 11 }, { middle_1_id: 12, middle_2: [ + { middle_2_id: 121 }, { middle_2_id: 122, bottom: [{ bottom_id: nil }] } + ] } + ] }, + { top_id: 2, middle_1: [ + { middle_1_id: 21 }, { middle_1_id: 22, middle_2: [{ middle_2_id: nil }] } + ] }, + { top_id: 3, middle_1: [ + { middle_1_id: nil }, { middle_1_id: 32 } + ] }, + { top_id: nil, missing_top_id: 4 } + ] + } + # debugger + get '/multi_level', data + expect(last_response.body.split(', ')).to contain_exactly( + 'top[3][top_id] is empty', + 'top[2][middle_1][0][middle_1_id] is empty', + 'top[1][middle_1][1][middle_2][0][middle_2_id] is empty', + 'top[0][middle_1][1][middle_2][1][bottom][0][bottom_id] is empty' + ) + expect(last_response.status).to eq(400) + end + end + end + + it 'exactly_one_of' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Hash do + optional :batch_no, type: String + optional :batch_id, type: String + exactly_one_of :batch_no, :batch_id + end + end + end + + subject.get '/exactly_one_of' do + 'exactly_one_of works!' + end + + data = { + orders: [ + { id: 77, drugs: { batch_no: 'A1234567' } }, + { id: 70 } + ] + } + + get '/exactly_one_of', data + expect(last_response.body).to eq('exactly_one_of works!') + expect(last_response.status).to eq(200) + end + + it 'at_least_one_of' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Hash do + optional :batch_no, type: String + optional :batch_id, type: String + at_least_one_of :batch_no, :batch_id + end + end + end + + subject.get '/at_least_one_of' do + 'at_least_one_of works!' + end + + data = { + orders: [ + { id: 77, drugs: { batch_no: 'A1234567' } }, + { id: 70 } + ] + } + + get '/at_least_one_of', data + expect(last_response.body).to eq('at_least_one_of works!') + expect(last_response.status).to eq(200) + end + + it 'all_or_none_of' do + subject.params do + requires :orders, type: Array do + requires :id, type: Integer + optional :drugs, type: Hash do + optional :batch_no, type: String + optional :batch_id, type: String + all_or_none_of :batch_no, :batch_id + end + end + end + + subject.get '/all_or_none_of' do + 'all_or_none_of works!' + end + + data = { + orders: [ + { id: 77, drugs: { batch_no: 'A1234567', batch_id: '12' } }, + { id: 70 } + ] + } + + get '/all_or_none_of', data + expect(last_response.body).to eq('all_or_none_of works!') + expect(last_response.status).to eq(200) end end @@ -905,15 +1181,25 @@ def validate_param!(attr_name, params) end context 'custom validation' do - module CustomValidations - class Customvalidator < Grape::Validations::Base + let(:custom_validator) do + Class.new(Grape::Validations::Validators::Base) do def validate_param!(attr_name, params) return if params[attr_name] == 'im custom' + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: 'is not custom!') end end end + before do + stub_const('CustomvalidatorValidator', custom_validator) + described_class.register(CustomvalidatorValidator) + end + + after do + described_class.deregister(:customvalidator) + end + context 'when using optional with a custom validator' do before do subject.params do @@ -1053,16 +1339,19 @@ def validate_param!(attr_name, params) end context 'when using options on param' do - module CustomValidations - class CustomvalidatorWithOptions < Grape::Validations::Base + let(:custom_validator_with_options) do + Class.new(Grape::Validations::Validators::Base) do def validate_param!(attr_name, params) return if params[attr_name] == @option[:text] + raise Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)], message: message) end end end before do + stub_const('CustomvalidatorWithOptionsValidator', custom_validator_with_options) + described_class.register(CustomvalidatorWithOptionsValidator) subject.params do optional :custom, customvalidator_with_options: { text: 'im custom with options', message: 'is not custom with options!' } end @@ -1071,6 +1360,10 @@ def validate_param!(attr_name, params) end end + after do + described_class.deregister(:customvalidator_with_options) + end + it 'validates param with custom validator with options' do get '/optional_custom', custom: 'im custom with options' expect(last_response.status).to eq(200) @@ -1084,24 +1377,6 @@ def validate_param!(attr_name, params) end context 'named' do - context 'can be defined' do - it 'in helpers' do - subject.helpers do - params :pagination do - end - end - end - - it 'in helper module which kind of Grape::DSL::Helpers::BaseHelper' do - shared_params = Module.new do - extend Grape::DSL::Helpers::BaseHelper - params :pagination do - end - end - subject.helpers shared_params - end - end - context 'can be included in usual params' do before do shared_params = Module.new do @@ -1126,14 +1401,14 @@ def validate_param!(attr_name, params) subject.params do use :pagination end - expect(declared_params).to eq %i[page per_page] + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq %i[page per_page] end it 'by #use with multiple params' do subject.params do use :pagination, :period end - expect(declared_params).to eq %i[page per_page start_date end_date] + expect(Grape::Validations::ParamsScope::Attr.attrs_keys(declared_params)).to eq %i[page per_page start_date end_date] end end @@ -1156,21 +1431,25 @@ def validate_param!(attr_name, params) } end end + it 'returns defaults' do get '/order' expect(last_response.status).to eq(200) expect(last_response.body).to eq({ order: :asc, order_by: :created_at }.to_json) end + it 'overrides default value for order' do get '/order?order=desc' expect(last_response.status).to eq(200) expect(last_response.body).to eq({ order: :desc, order_by: :created_at }.to_json) end + it 'overrides default value for order_by' do get '/order?order_by=name' expect(last_response.status).to eq(200) expect(last_response.body).to eq({ order: :asc, order_by: :name }.to_json) end + it 'fails with invalid value' do get '/order?order=invalid' expect(last_response.status).to eq(400) @@ -1179,23 +1458,58 @@ def validate_param!(attr_name, params) end end - context 'documentation' do - it 'can be included with a hash' do - documentation = { example: 'Joe' } + context 'with block and keyword argument' do + before do + subject.helpers do + params :shared_params do |type:| + optional :param, default: type + end + end + subject.format :json + subject.params do + use :shared_params, type: 'value' + end + subject.get '/shared_params' do + { + param: params[:param] + } + end + end + + it 'works' do + get '/shared_params' + expect(last_response.status).to eq(200) + expect(last_response.body).to eq({ param: 'value' }.to_json) + end + end + + context 'with block and empty args' do + before do + subject.helpers do + params :shared_params do |empty_args| + optional :param, default: empty_args[:some] + end + end + subject.format :json subject.params do - requires 'first_name', documentation: documentation + use :shared_params end - subject.get '/' do + subject.get '/shared_params' do + :ok end + end + + it 'works' do + get '/shared_params' - expect(subject.routes.first.params['first_name'][:documentation]).to eq(documentation) + expect(last_response.status).to eq(200) end end context 'all or none' do context 'optional params' do - before :each do + before do subject.resource :custom_message do params do optional :beer @@ -1208,17 +1522,20 @@ def validate_param!(attr_name, params) end end end + context 'with a custom validation message' do it 'errors when any one is present' do get '/custom_message/all_or_none', beer: 'string' expect(last_response.status).to eq(400) expect(last_response.body).to eq 'beer, wine, juice all params are required or none is required' end + it 'works when all params are present' do get '/custom_message/all_or_none', beer: 'string', wine: 'anotherstring', juice: 'anotheranotherstring' expect(last_response.status).to eq(200) expect(last_response.body).to eq 'all_or_none works!' end + it 'works when none are present' do get '/custom_message/all_or_none' expect(last_response.status).to eq(200) @@ -1382,7 +1699,7 @@ def validate_param!(attr_name, params) context 'exactly one of' do context 'params' do - before :each do + before do subject.resources :custom_message do params do optional :beer @@ -1446,7 +1763,7 @@ def validate_param!(attr_name, params) end context 'nested params' do - before :each do + before do subject.params do requires :nested, type: Hash do optional :beer_nested @@ -1488,7 +1805,7 @@ def validate_param!(attr_name, params) context 'at least one of' do context 'params' do - before :each do + before do subject.resources :custom_message do params do optional :beer @@ -1552,7 +1869,7 @@ def validate_param!(attr_name, params) end context 'nested params' do - before :each do + before do subject.params do requires :nested, type: Hash do optional :beer @@ -1665,5 +1982,65 @@ def validate_param!(attr_name, params) expect(last_response.status).to eq(400) end end + + # Ensure there is no leakage of indices between requests + context 'required with a hash inside an array' do + before do + subject.params do + requires :items, type: Array do + requires :item, type: Hash do + requires :name, type: String + end + end + end + subject.post '/required' do + 'required works' + end + end + + let(:valid_item) { { item: { name: 'foo' } } } + + let(:params) do + { + items: [ + valid_item, + valid_item, + {} + ] + } + end + + it 'makes sure the error message is independent of the previous request' do + post_with_json '/required', {} + expect(last_response).to be_bad_request + expect(last_response.body).to eq('items is missing, items[item][name] is missing') + + post_with_json '/required', params + expect(last_response).to be_bad_request + expect(last_response.body).to eq('items[2][item] is missing, items[2][item][name] is missing') + + post_with_json '/required', {} + expect(last_response).to be_bad_request + expect(last_response.body).to eq('items is missing, items[item][name] is missing') + end + end + end + + describe 'require_validator' do + subject { described_class.require_validator(short_name) } + + context 'when found' do + let(:short_name) { :presence } + + it { is_expected.to be(Grape::Validations::Validators::PresenceValidator) } + end + + context 'when not found' do + let(:short_name) { :test } + + it 'raises an error' do + expect { subject }.to raise_error(Grape::Exceptions::UnknownValidator) + end + end end end diff --git a/spec/integration/dry_validation/dry_validation_spec.rb b/spec/integration/dry_validation/dry_validation_spec.rb new file mode 100644 index 000000000..d7b2f8efa --- /dev/null +++ b/spec/integration/dry_validation/dry_validation_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +describe 'Dry::Schema', if: defined?(Dry::Schema) do + describe 'Grape::DSL::Validations' do + subject { app } + + let(:app) do + Class.new do + include Grape::DSL::Validations + end + end + + describe '.reset_validations!' do + before do + subject.namespace_stackable :declared_params, ['dummy'] + subject.namespace_stackable :validations, ['dummy'] + subject.namespace_stackable :params, ['dummy'] + subject.route_setting :description, description: 'lol', params: ['dummy'] + subject.reset_validations! + end + + after do + subject.unset_route_setting :description + end + + it 'resets declared params' do + expect(subject.namespace_stackable(:declared_params)).to be_empty + end + + it 'resets validations' do + expect(subject.namespace_stackable(:validations)).to be_empty + end + + it 'resets params' do + expect(subject.namespace_stackable(:params)).to be_empty + end + + it 'does not reset documentation description' do + expect(subject.route_setting(:description)[:description]).to eq 'lol' + end + end + + describe '.params' do + it 'returns a ParamsScope' do + expect(subject.params).to be_a Grape::Validations::ParamsScope + end + + it 'evaluates block' do + 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 + + 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).to be_created + expect(validated_params).to eq('number' => 1) + end + + it 'shows expected validation error' do + post '/required' + expect(last_response).to be_bad_request + 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).to be_created + expect(validated_params).to eq('number' => 1, 'name' => '2') + end + + it 'shows expected validation error' do + post '/required', number: '6' + expect(last_response).to be_bad_request + 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).to be_created + 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).to be_bad_request + 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).to be_created + 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).to be_bad_request + 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).to be_created + 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).to be_created + 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).to be_bad_request + expect(last_response.body).to eq('foo_id is not allowed') + end + end + end +end diff --git a/spec/integration/eager_load/eager_load_spec.rb b/spec/integration/eager_load/eager_load_spec.rb deleted file mode 100644 index 94b78e3e0..000000000 --- a/spec/integration/eager_load/eager_load_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', '..', 'lib')) -require 'grape' - -describe Grape do - it 'eager_load!' do - require 'grape/eager_load' - expect { Grape.eager_load! }.to_not raise_error - end - - it 'compile!' do - expect { Class.new(Grape::API).compile! }.to_not raise_error - end -end diff --git a/spec/grape/entity_spec.rb b/spec/integration/grape_entity/entity_spec.rb similarity index 69% rename from spec/grape/entity_spec.rb rename to spec/integration/grape_entity/entity_spec.rb index 08c378ef2..eaea82382 100644 --- a/spec/grape/entity_spec.rb +++ b/spec/integration/grape_entity/entity_spec.rb @@ -1,16 +1,22 @@ # frozen_string_literal: true -require 'spec_helper' -require 'grape_entity' +require 'rack/contrib/jsonp' -describe Grape::Entity do - subject { Class.new(Grape::API) } +describe 'Grape::Entity', if: defined?(Grape::Entity) do + describe '#present' do + subject { Class.new(Grape::API) } - def app - subject - end + let(:app) { subject } + + before do + stub_const('TestObject', Class.new) + stub_const('FakeCollection', Class.new do + def first + TestObject.new + end + end) + end - describe '#present' do it 'sets the object as the body if no options are provided' do inner_body = nil subject.get '/example' do @@ -21,16 +27,6 @@ def app expect(inner_body).to eql(abc: 'def') end - it 'calls through to the provided entity class if one is given' do - entity_mock = Object.new - allow(entity_mock).to receive(:represent) - - subject.get '/example' do - present Object.new, with: entity_mock - end - get '/example' - end - it 'pulls a representation from the class options if it exists' do entity = Class.new(Grape::Entity) allow(entity).to receive(:represent).and_return('Hiya') @@ -47,24 +43,13 @@ def app entity = Class.new(Grape::Entity) allow(entity).to receive(:represent).and_return('Hiya') - module EntitySpec - class TestObject - end - - class FakeCollection - def first - TestObject.new - end - end - end - - subject.represent EntitySpec::TestObject, with: entity + subject.represent TestObject, with: entity subject.get '/example' do - present [EntitySpec::TestObject.new] + present [TestObject.new] end subject.get '/example2' do - present EntitySpec::FakeCollection.new + present FakeCollection.new end get '/example' @@ -116,7 +101,7 @@ def first expect(last_response.body).to eq('Auto-detect!') end - it 'does not run autodetection for Entity when explicitely provided' do + it 'does not run autodetection for Entity when explicitly provided' do entity = Class.new(Grape::Entity) some_array = [] @@ -181,6 +166,7 @@ def first subject.get '/example' do c = Class.new do attr_reader :id + def initialize(id) @id = id end @@ -189,7 +175,7 @@ def initialize(id) end get '/example' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('{"example":{"id":1}}') end @@ -202,6 +188,7 @@ def initialize(id) subject.get '/examples' do c = Class.new do attr_reader :id + def initialize(id) @id = id end @@ -211,7 +198,7 @@ def initialize(id) end get '/examples' - expect(last_response.status).to eq(200) + expect(last_response).to be_successful expect(last_response.body).to eq('{"examples":[{"id":1},{"id":2}]}') end end @@ -226,6 +213,7 @@ def initialize(id) subject.get '/example' do c = Class.new do attr_reader :name + def initialize(args) @name = args[:name] || 'no name set' end @@ -233,16 +221,16 @@ def initialize(args) present c.new(name: 'johnnyiller'), with: entity end get '/example' - expect(last_response.status).to eq(200) - expect(last_response.headers['Content-type']).to eq('application/xml') - expect(last_response.body).to eq <<-XML - - - - johnnyiller - - -XML + expect(last_response).to be_successful + expect(last_response.content_type).to eq('application/xml') + expect(last_response.body).to eq <<~XML + + + + johnnyiller + + + XML end it 'presents with json' do @@ -255,6 +243,7 @@ def initialize(args) subject.get '/example' do c = Class.new do attr_reader :name + def initialize(args) @name = args[:name] || 'no name set' end @@ -262,13 +251,12 @@ def initialize(args) present c.new(name: 'johnnyiller'), with: entity end get '/example' - expect(last_response.status).to eq(200) - expect(last_response.headers['Content-type']).to eq('application/json') + expect(last_response).to be_successful + expect(last_response.content_type).to eq('application/json') expect(last_response.body).to eq('{"example":{"name":"johnnyiller"}}') end it 'presents with jsonp utilising Rack::JSONP' do - # Include JSONP middleware subject.use Rack::JSONP entity = Class.new(Grape::Entity) @@ -284,6 +272,7 @@ def initialize(args) subject.get '/example' do c = Class.new do attr_reader :name + def initialize(args) @name = args[:name] || 'no name set' end @@ -293,8 +282,8 @@ def initialize(args) end get '/example?callback=abcDef' - expect(last_response.status).to eq(200) - expect(last_response.headers['Content-type']).to eq('application/javascript') + expect(last_response).to be_successful + expect(last_response.content_type).to eq('application/javascript') expect(last_response.body).to include 'abcDef({"example":{"name":"johnnyiller"}})' end @@ -302,6 +291,7 @@ def initialize(args) it 'present with multiple entities using optional symbol' do user = Class.new do attr_reader :name + def initialize(args) @name = args[:name] || 'no name set' end @@ -320,7 +310,7 @@ def initialize(args) end get '/example' expect_response_json = { - 'page' => 1, + 'page' => 1, 'user1' => { 'name' => 'user1' }, 'user2' => { 'name' => 'user2' } } @@ -328,4 +318,104 @@ def initialize(args) end end end + + describe 'Grape::Middleware::Error' do + let(:error_entity) do + Class.new(Grape::Entity) do + expose :code + expose :static + + def static + 'static text' + end + end + end + let(:options) { { default_message: 'Aww, hamburgers.' } } + + let(:error_app) do + Class.new do + class << self + attr_accessor :error, :format + + def call(_env) + throw :error, error + end + end + end + end + + let(:app) do + opts = options + Rack::Builder.app do + use Spec::Support::EndpointFaker + use Grape::Middleware::Error, opts + run ErrApp + end + end + + before do + stub_const('ErrApp', error_app) + stub_const('ErrorEntity', error_entity) + end + + context 'with http code' do + it 'presents an error message' do + ErrApp.error = { message: { code: 200, with: ErrorEntity } } + get '/' + + expect(last_response.body).to eq({ code: 200, static: 'static text' }.to_json) + end + end + end + + describe 'error_presenter' do + subject { last_response } + + let(:error_presenter) do + Class.new(Grape::Entity) do + expose :code + expose :static + + def static + 'some static text' + end + end + end + + before do + stub_const('ErrorPresenter', error_presenter) + get '/exception' + end + + context 'when using http_codes' do + let(:app) do + Class.new(Grape::API) do + desc 'some desc', http_codes: [[408, 'Unauthorized', ErrorPresenter]] + get '/exception' do + error!({ code: 408 }, 408) + end + end + end + + it 'is used as presenter' do + expect(subject).to be_request_timeout + expect(subject.body).to eql({ code: 408, static: 'some static text' }.to_json) + end + end + + context 'when using with' do + let(:app) do + Class.new(Grape::API) do + get '/exception' do + error!({ code: 408, with: ErrorPresenter }, 408) + end + end + end + + it 'presented with' do + expect(subject).to be_request_timeout + expect(subject.body).to eql({ code: 408, static: 'some static text' }.to_json) + end + end + end end diff --git a/spec/integration/hashie/hashie_spec.rb b/spec/integration/hashie/hashie_spec.rb new file mode 100644 index 000000000..8d9021c6d --- /dev/null +++ b/spec/integration/hashie/hashie_spec.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +describe 'Hashie', if: defined?(Hashie) do + subject { app } + + let(:app) { Class.new(Grape::API) } + + describe 'Grape::Extensions::Hashie::Mash::ParamBuilder' do + describe 'deprecation' do + context 'when included' do + subject do + Class.new(Grape::API) do + include Grape::Extensions::Hashie::Mash::ParamBuilder + end + end + + let(:message) do + 'This concern has been deprecated. Use `build_with` with one of the following short_name (:hash, :hash_with_indifferent_access, :hashie_mash) instead.' + end + + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with(message).and_raise(ActiveSupport::DeprecationException, :deprecated) + expect { subject }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') + end + end + + context 'when using class name' do + let(:app) do + Class.new(Grape::API) do + params do + build_with Grape::Extensions::Hashie::Mash::ParamBuilder + end + get + end + end + + it 'raises a deprecation' do + expect(Grape.deprecator).to receive(:warn).with('Grape::Extensions::Hashie::Mash::ParamBuilder has been deprecated. Use short name :hashie_mash instead.').and_raise(ActiveSupport::DeprecationException, + :deprecated) + expect { get '/' }.to raise_error(ActiveSupport::DeprecationException, 'deprecated') + end + end + end + end + + describe 'Grape::ParamsBuilder::HashieMash' do + describe 'in an endpoint' do + describe '#params' do + before do + subject.params do + build_with :hashie_mash + end + + subject.get do + params.class + end + end + + it 'is of type Hashie::Mash' do + get '/' + expect(last_response).to be_successful + expect(last_response.body).to eq('Hashie::Mash') + end + end + end + + describe 'in an api' do + before do + subject.build_with :hashie_mash + end + + describe '#params' do + before do + subject.get do + params.class + end + end + + it 'is Hashie::Mash' do + get '/' + expect(last_response).to be_successful + expect(last_response.body).to eq('Hashie::Mash') + end + end + + context 'in a nested namespace api' do + before do + subject.namespace :foo do + get do + params.class + end + end + end + + it 'is Hashie::Mash' do + get '/foo' + expect(last_response).to be_successful + expect(last_response.body).to eq('Hashie::Mash') + end + end + + it 'is indifferent to key or symbol access' do + subject.params do + build_with :hashie_mash + requires :a, type: String + end + subject.get '/foo' do + [params[:a], params['a']] + end + + get '/foo', a: 'bar' + expect(last_response).to be_successful + expect(last_response.body).to eq('["bar", "bar"]') + end + + it 'does not overwrite route_param with a regular param if they have same name' do + subject.namespace :route_param do + route_param :foo do + get { params.to_json } + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response).to be_successful + expect(last_response.body).to eq('{"foo":"bar"}') + end + + it 'does not overwrite route_param with a defined regular param if they have same name' do + subject.namespace :route_param do + params do + build_with :hashie_mash + requires :foo, type: String + end + route_param :foo do + get do + [params[:foo], params['foo']] + end + end + end + + get '/route_param/bar', foo: 'baz' + expect(last_response).to be_successful + expect(last_response.body).to eq('["bar", "bar"]') + end + end + end + + describe 'Grape::Request' do + let(:default_method) { Rack::GET } + let(:default_params) { {} } + let(:default_options) do + { + method: method, + params: params + } + end + let(:default_env) do + Rack::MockRequest.env_for('/', options) + end + let(:method) { default_method } + let(:params) { default_params } + let(:options) { default_options } + let(:env) { default_env } + let(:request) { Grape::Request.new(env) } + + describe '#params' do + let(:params) do + { + a: '123', + b: 'xyz' + } + end + + it 'by default returns stringified parameter keys' do + expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new('a' => '123', 'b' => 'xyz')) + end + + context 'when build_params_with: Grape::Extensions::Hash::ParamBuilder is specified' do + let(:request) { Grape::Request.new(env, build_params_with: :hash) } + + it 'returns symbolized params' do + expect(request.params).to eq(a: '123', b: 'xyz') + end + end + + describe 'with grape.routing_args' do + let(:options) do + default_options.merge('grape.routing_args' => routing_args) + end + let(:routing_args) do + { + version: '123', + route_info: '456', + c: 'ccc' + } + end + + it 'cuts version and route_info' do + expect(request.params).to eq(ActiveSupport::HashWithIndifferentAccess.new(a: '123', b: 'xyz', c: 'ccc')) + end + end + end + + describe 'when the build_params_with is set to Hashie' do + subject(:request_params) { Grape::Request.new(env, build_params_with: :hashie_mash).params } + + context 'when the API includes a specific param builder' do + it { is_expected.to be_a(Hashie::Mash) } + end + end + end + + describe 'Grape::Validations::Validators::CoerceValidator' do + context 'when params is Hashie::Mash' do + context 'for primitive collections' do + before do + subject.params do + build_with :hashie_mash + optional :a, types: [String, Array[String]] + optional :b, types: [Array[Integer], Array[String]] + optional :c, type: Array[Integer, String] + optional :d, types: [Integer, String, Set[Integer, String]] + end + subject.get '/' do + ( + params.a || + params.b || + params.c || + params.d + ).inspect + end + end + + it 'allows singular form declaration' do + get '/', a: 'one way' + expect(last_response).to be_successful + expect(last_response.body).to eq('"one way"') + + get '/', a: %w[the other] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + + get '/', a: { a: 1, b: 2 } + expect(last_response).to be_bad_request + expect(last_response.body).to eq('a is invalid') + + get '/', a: [1, 2, 3] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + end + + it 'allows multiple collection types' do + get '/', b: [1, 2, 3] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + + get '/', b: %w[1 2 3] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + + get '/', b: [1, true, 'three'] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + end + + it 'allows collections with multiple types' do + get '/', c: [1, '2', true, 'three'] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + + get '/', d: '1' + expect(last_response).to be_successful + expect(last_response.body).to eq('1') + + get '/', d: 'one' + expect(last_response).to be_successful + expect(last_response.body).to eq('"one"') + + get '/', d: %w[1 two] + expect(last_response).to be_successful + expect(last_response.body).to eq('#') + end + end + end + end + + describe 'Grape::Endpoint' do + before do + subject.format :json + subject.params do + requires :first + optional :second + optional :third, default: 'third-default' + optional :multiple_types, types: [Integer, String] + optional :nested, type: Hash do + optional :fourth + optional :fifth + optional :nested_two, type: Hash do + optional :sixth + optional :nested_three, type: Hash do + optional :seventh + end + end + optional :nested_arr, type: Array do + optional :eighth + end + optional :empty_arr, type: Array + optional :empty_typed_arr, type: Array[String] + optional :empty_hash, type: Hash + optional :empty_set, type: Set + optional :empty_typed_set, type: Set[String] + end + optional :arr, type: Array do + optional :nineth + end + optional :empty_arr, type: Array + optional :empty_typed_arr, type: Array[String] + optional :empty_hash, type: Hash + optional :empty_hash_two, type: Hash + optional :empty_set, type: Set + optional :empty_typed_set, type: Set[String] + end + end + + context 'when params are not built with default class' do + it 'returns an object that corresponds with the params class - hashie mash' do + subject.params do + build_with :hashie_mash + end + subject.get '/declared' do + d = declared(params, include_missing: true) + { declared_class: d.class.to_s } + end + + get '/declared?first=present' + expect(JSON.parse(last_response.body)['declared_class']).to eq('Hashie::Mash') + end + end + end +end diff --git a/spec/integration/multi_json/json_spec.rb b/spec/integration/multi_json/json_spec.rb index fc06602fd..56ded26b0 100644 --- a/spec/integration/multi_json/json_spec.rb +++ b/spec/integration/multi_json/json_spec.rb @@ -1,9 +1,8 @@ # frozen_string_literal: true -require 'spec_helper' +# grape_entity depends on multi-json and it breaks the test. +describe Grape::Json, if: defined?(MultiJson) && !defined?(Grape::Entity) do + subject { described_class } -describe Grape::Json do - it 'uses multi_json' do - expect(Grape::Json).to eq(::MultiJson) - end + it { is_expected.to eq(MultiJson) } end diff --git a/spec/integration/multi_xml/xml_spec.rb b/spec/integration/multi_xml/xml_spec.rb index dde1fec5c..a5d998847 100644 --- a/spec/integration/multi_xml/xml_spec.rb +++ b/spec/integration/multi_xml/xml_spec.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -require 'spec_helper' +describe Grape::Xml, if: defined?(MultiXml) do + subject { described_class } -describe Grape::Xml do - it 'uses multi_xml' do - expect(Grape::Xml).to eq(::MultiXml) - end + it { is_expected.to eq(MultiXml) } end diff --git a/spec/integration/rack_3_0/headers_spec.rb b/spec/integration/rack_3_0/headers_spec.rb new file mode 100644 index 000000000..735807c7e --- /dev/null +++ b/spec/integration/rack_3_0/headers_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +describe Grape::API do + subject { last_response.headers } + + describe 'returned headers should all be in lowercase' do + context 'when setting an header in an API' do + let(:app) do + Class.new(described_class) do + get do + header['GRAPE'] = '1' + return_no_content + end + end + end + + before { get '/' } + + it { is_expected.to include('grape' => '1') } + end + + context 'when error!' do + let(:app) do + Class.new(described_class) do + rescue_from ArgumentError do + error!('error!', 500, { 'GRAPE' => '1' }) + end + + get { raise ArgumentError } + end + end + + before { get '/' } + + it { is_expected.to include('grape' => '1') } + end + + context 'when redirect' do + let(:app) do + Class.new(described_class) do + get do + redirect 'https://www.ruby-grape.org/' + end + end + end + + before { get '/' } + + it { is_expected.to include('location' => 'https://www.ruby-grape.org/') } + end + + context 'when options' do + let(:app) do + Class.new(described_class) do + get { return_no_content } + end + end + + before { options '/' } + + it { is_expected.to include('allow' => 'OPTIONS, GET, HEAD') } + end + + context 'when cascade' do + let(:app) do + Class.new(described_class) do + version 'v0', using: :path, cascade: true + get { return_no_content } + end + end + + before { get '/v1' } + + it { is_expected.to include('x-cascade' => 'pass') } + end + end +end diff --git a/spec/integration/rack_3_0/version_spec.rb b/spec/integration/rack_3_0/version_spec.rb new file mode 100644 index 000000000..1352ee6a5 --- /dev/null +++ b/spec/integration/rack_3_0/version_spec.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +describe Rack do + it { expect(Gem::Version.new(described_class.release).segments.first).to eq 3 } +end diff --git a/spec/integration/rails/mounting_spec.rb b/spec/integration/rails/mounting_spec.rb new file mode 100644 index 000000000..3d8288b93 --- /dev/null +++ b/spec/integration/rails/mounting_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +describe 'Rails', if: defined?(Rails) do + context 'rails mounted' do + let(:api) do + Class.new(Grape::API) do + get('/test_grape') { 'rails mounted' } + end + end + + let(:app) do + require 'rails' + require 'action_controller/railtie' + + # https://github.com/rails/rails/issues/51784 + # same error as described if not redefining the following + ActiveSupport::Dependencies.autoload_paths = [] + ActiveSupport::Dependencies.autoload_once_paths = [] + + Class.new(Rails::Application) do + config.eager_load = false + config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" + config.api_only = true + config.consider_all_requests_local = true + config.hosts << 'example.org' + + routes.append do + mount GrapeApi => '/' + + get 'up', to: lambda { |_env| + ['200', {}, ['hello world']] + } + end + end + end + + before do + stub_const('GrapeApi', api) + app.initialize! + end + + it 'cascades' do + get '/test_grape' + expect(last_response).to be_successful + expect(last_response.body).to eq('rails mounted') + get '/up' + expect(last_response).to be_successful + expect(last_response.body).to eq('hello world') + end + end +end diff --git a/spec/integration/rails/railtie_spec.rb b/spec/integration/rails/railtie_spec.rb new file mode 100644 index 000000000..7583fc29c --- /dev/null +++ b/spec/integration/rails/railtie_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +if defined?(Rails) && ActiveSupport.gem_version >= Gem::Version.new('7.1') + describe Grape::Railtie do + describe '.railtie' do + subject { test_app.deprecators[:grape] } + + let(:test_app) do + # https://github.com/rails/rails/issues/51784 + # same error as described if not redefining the following + ActiveSupport::Dependencies.autoload_paths = [] + ActiveSupport::Dependencies.autoload_once_paths = [] + + Class.new(Rails::Application) do + config.eager_load = false + config.load_defaults "#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}" + end + end + + before { test_app.initialize! } + + it { is_expected.to be(Grape.deprecator) } + end + end +end diff --git a/spec/shared/deprecated_class_examples.rb b/spec/shared/deprecated_class_examples.rb new file mode 100644 index 000000000..7909798ea --- /dev/null +++ b/spec/shared/deprecated_class_examples.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'deprecated class' do + subject { deprecated_class.new } + + around do |example| + old_deprec_behavior = Grape.deprecator.behavior + Grape.deprecator.behavior = :raise + example.run + Grape.deprecator.behavior = old_deprec_behavior + end + + it 'raises an ActiveSupport::DeprecationException' do + expect { subject }.to raise_error(ActiveSupport::DeprecationException) + end +end diff --git a/spec/shared/versioning_examples.rb b/spec/shared/versioning_examples.rb index 8e8552070..9f42eeda1 100644 --- a/spec/shared/versioning_examples.rb +++ b/spec/shared/versioning_examples.rb @@ -5,7 +5,7 @@ subject.format :txt subject.version 'v1', macro_options subject.get :hello do - "Version: #{request.env['api.version']}" + "Version: #{request.env[Grape::Env::API_VERSION]}" end versioned_get '/hello', 'v1', macro_options expect(last_response.body).to eql 'Version: v1' @@ -16,7 +16,7 @@ subject.prefix 'api' subject.version 'v1', macro_options subject.get :hello do - "Version: #{request.env['api.version']}" + "Version: #{request.env[Grape::Env::API_VERSION]}" end versioned_get '/hello', 'v1', macro_options.merge(prefix: 'api') expect(last_response.body).to eql 'Version: v1' @@ -35,14 +35,14 @@ end versioned_get '/awesome', 'v1', macro_options - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 versioned_get '/awesome', 'v2', macro_options - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 versioned_get '/legacy', 'v1', macro_options - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 versioned_get '/legacy', 'v2', macro_options - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 end it 'is able to specify multiple versions' do @@ -52,11 +52,11 @@ end versioned_get '/awesome', 'v1', macro_options - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 versioned_get '/awesome', 'v2', macro_options - expect(last_response.status).to eql 200 + expect(last_response.status).to be 200 versioned_get '/awesome', 'v3', macro_options - expect(last_response.status).to eql 404 + expect(last_response.status).to be 404 end context 'with different versions for the same endpoint' do @@ -65,12 +65,12 @@ subject.format :txt subject.version 'v2', macro_options subject.get 'version' do - request.env['api.version'] + request.env[Grape::Env::API_VERSION] end subject.version 'v1', macro_options do get 'version' do - 'version ' + request.env['api.version'] + "version #{request.env[Grape::Env::API_VERSION]}" end end @@ -89,12 +89,12 @@ subject.prefix 'api' subject.version 'v2', macro_options subject.get 'version' do - request.env['api.version'] + request.env[Grape::Env::API_VERSION] end subject.version 'v1', macro_options do get 'version' do - 'version ' + request.env['api.version'] + "version #{request.env[Grape::Env::API_VERSION]}" end end @@ -117,6 +117,7 @@ before do @output ||= 'v2-' end + get 'version' do @output += 'version' end @@ -126,6 +127,7 @@ before do @output ||= 'v1-' end + get 'version' do @output += 'version' end @@ -170,6 +172,7 @@ end klass end + before do subject.format :txt diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index db3cf8b7b..06cb282a0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,47 +1,35 @@ # frozen_string_literal: true -$LOAD_PATH.unshift(File.dirname(__FILE__)) -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) -$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'support')) - -require 'grape' - +require 'simplecov' require 'rubygems' require 'bundler' Bundler.require :default, :test -Dir["#{File.dirname(__FILE__)}/support/*.rb"].each do |file| - require file +Grape.deprecator.behavior = :raise + +%w[config support].each do |dir| + Dir["#{File.dirname(__FILE__)}/#{dir}/**/*.rb"].sort.each do |file| + require file + end end -eager_load! +Grape::Util::Registry.include(Deregister) +# issue with ruby 2.7 with ^. We need to extend it again +Grape::Validations.extend(Grape::Util::Registry) if Gem::Version.new(RUBY_VERSION).release < Gem::Version.new('3.0') # The default value for this setting is true in a standard Rails app, # so it should be set to true here as well to reflect that. I18n.enforce_available_locales = true -module Chunks - def read_chunks(body) - buffer = [] - body.each { |chunk| buffer << chunk } - - buffer - end -end - RSpec.configure do |config| - config.include Chunks config.include Rack::Test::Methods config.include Spec::Support::Helpers config.raise_errors_for_deprecations! config.filter_run_when_matching :focus - config.warnings = true - config.before(:each) { Grape::Util::InheritableSetting.reset_global! } + config.before(:all) { Grape::Util::InheritableSetting.reset_global! } + config.before { Grape::Util::InheritableSetting.reset_global! } # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = '.rspec_status' end - -require 'coveralls' -Coveralls.wear! diff --git a/spec/support/basic_auth_encode_helpers.rb b/spec/support/basic_auth_encode_helpers.rb index c58acada6..78e21e6c8 100644 --- a/spec/support/basic_auth_encode_helpers.rb +++ b/spec/support/basic_auth_encode_helpers.rb @@ -4,7 +4,7 @@ module Spec module Support module Helpers def encode_basic_auth(username, password) - 'Basic ' + Base64.encode64("#{username}:#{password}") + "Basic #{Base64.encode64("#{username}:#{password}")}" end end end diff --git a/spec/support/chunked_response.rb b/spec/support/chunked_response.rb new file mode 100644 index 000000000..4c66e9384 --- /dev/null +++ b/spec/support/chunked_response.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# this is a copy of Rack::Chunked which has been removed in rack > 3.0 + +class ChunkedResponse + class Body + TERM = "\r\n" + TAIL = "0#{TERM}" + + # Store the response body to be chunked. + def initialize(body) + @body = body + end + + # For each element yielded by the response body, yield + # the element in chunked encoding. + def each(&block) + term = TERM + @body.each do |chunk| + size = chunk.bytesize + next if size == 0 + + yield [size.to_s(16), term, chunk.b, term].join + end + yield TAIL + yield_trailers(&block) + yield term + end + + # Close the response body if the response body supports it. + def close + @body.try(:close) + end + + private + + # Do nothing as this class does not support trailer headers. + def yield_trailers; end + end + + class TrailerBody < Body + private + + # Yield strings for each trailer header. + def yield_trailers + @body.trailers.each_pair do |k, v| + yield "#{k}: #{v}\r\n" + end + end + end + + def initialize(app) + @app = app + end + + def call(env) + status, headers, body = response = @app.call(env) + + if !Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.key?(status.to_i) && + !headers[Rack::CONTENT_LENGTH] && + !headers['Transfer-Encoding'] + + headers['Transfer-Encoding'] = 'chunked' + response[2] = if headers['trailer'] + TrailerBody.new(body) + else + Body.new(body) + end + end + + response + end +end diff --git a/spec/support/cookie_jar.rb b/spec/support/cookie_jar.rb new file mode 100644 index 000000000..e36ec66cc --- /dev/null +++ b/spec/support/cookie_jar.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'uri' + +module Rack + class MockResponse + def cookie_jar + @cookie_jar ||= Array(headers[Rack::SET_COOKIE]).flat_map { |h| h.split("\n") }.map { |c| Cookie.new(c).to_h } + end + + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie + class Cookie + attr_reader :attributes + + def initialize(raw) + @attributes = raw.split(/;\s*/).flat_map.with_index do |attribute, i| + attribute, value = attribute.split('=', 2) + if i.zero? + [['name', attribute], ['value', unescape(value)]] + else + [[attribute.downcase, parse_value(attribute, value)]] + end + end.to_h.freeze + end + + def to_h + @attributes.dup + end + + def to_s + @attributes.to_s + end + + private + + def unescape(value) + URI.decode_www_form_component(value, Encoding::UTF_8) + end + + def parse_value(attribute, value) + case attribute + when 'expires' + Time.parse(value) + when 'max-age' + value.to_i + when 'secure', 'httponly', 'partitioned' + true + else + unescape(value) + end + end + end + end +end diff --git a/spec/support/deprecated_warning_handlers.rb b/spec/support/deprecated_warning_handlers.rb new file mode 100644 index 000000000..040410fb1 --- /dev/null +++ b/spec/support/deprecated_warning_handlers.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Warning[:deprecated] = true + +module DeprecatedWarningHandler + class DeprecationWarning < StandardError; end + + DEPRECATION_REGEX = /is deprecated/.freeze + + def warn(message) + return super unless message.match?(DEPRECATION_REGEX) + + exception = DeprecationWarning.new(message) + exception.set_backtrace(caller) + raise exception + end +end + +Warning.singleton_class.prepend(DeprecatedWarningHandler) diff --git a/spec/support/deregister.rb b/spec/support/deregister.rb new file mode 100644 index 000000000..f5dc7e5b2 --- /dev/null +++ b/spec/support/deregister.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Deregister + def deregister(key) + registry.delete(key) + end +end diff --git a/spec/support/eager_load.rb b/spec/support/eager_load.rb deleted file mode 100644 index c55e4f243..000000000 --- a/spec/support/eager_load.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -# Grape uses autoload https://api.rubyonrails.org/classes/ActiveSupport/Autoload.html. -# When a class/module get added to the list, ActiveSupport doesn't check whether it really exists. -# This method loads all classes/modules defined via autoload to be sure only existing -# classes/modules were listed. -def eager_load!(scope = Grape) - # get modules - scope.constants.each do |const_name| - const = scope.const_get(const_name) - - next unless const.respond_to?(:eager_load!) - - const.eager_load! - - # check its modules, they might need to be loaded as well. - eager_load!(const) - end -end diff --git a/spec/support/endpoint_faker.rb b/spec/support/endpoint_faker.rb index 1cf02ef1d..8e597520e 100644 --- a/spec/support/endpoint_faker.rb +++ b/spec/support/endpoint_faker.rb @@ -4,8 +4,7 @@ module Spec module Support class EndpointFaker class FakerAPI < Grape::API - get '/' do - end + get('/') end def initialize(app, endpoint = FakerAPI.endpoints.first) @@ -18,7 +17,7 @@ def call(env) @request = Grape::Request.new(env.dup) end - @app.call(env.merge('api.endpoint' => @endpoint)) + @app.call(env.merge(Grape::Env::API_ENDPOINT => @endpoint)) end end end diff --git a/spec/support/versioned_helpers.rb b/spec/support/versioned_helpers.rb index 8d7c07f18..ce682e0d7 100644 --- a/spec/support/versioned_helpers.rb +++ b/spec/support/versioned_helpers.rb @@ -6,27 +6,21 @@ module Support module Helpers # Returns the path with options[:version] prefixed if options[:using] is :path. # Returns normal path otherwise. - def versioned_path(**options) + def versioned_path(options) case options[:using] when :path File.join('/', options[:prefix] || '', options[:version], options[:path]) - when :param - File.join('/', options[:prefix] || '', options[:path]) - when :header - File.join('/', options[:prefix] || '', options[:path]) - when :accept_version_header + when :param, :header, :accept_version_header File.join('/', options[:prefix] || '', options[:path]) else raise ArgumentError.new("unknown versioning strategy: #{options[:using]}") end end - def versioned_headers(**options) + def versioned_headers(options) case options[:using] - when :path - {} # no-op - when :param - {} # no-op + when :path, :param + {} when :header { 'HTTP_ACCEPT' => [ @@ -43,9 +37,9 @@ def versioned_headers(**options) end end - def versioned_get(path, version_name, **version_options) - path = versioned_path(version_options.merge(version: version_name, path: path)) - headers = versioned_headers(**version_options.merge(version: version_name)) + def versioned_get(path, version_name, version_options) + path = versioned_path(version_options.merge(version: version_name, path: path)) + headers = versioned_headers(version_options.merge(version: version_name)) params = {} params = { version_options[:parameter] => version_name } if version_options[:using] == :param get path, params, headers