├── .github └── workflows │ └── main.yml ├── .gitignore ├── .helix └── languages.toml ├── .redocly.lint-ignore.yaml ├── .rspec ├── .rubocop.yml ├── .rubocop_todo.yml ├── .ruby-gemset ├── .ruby-version ├── Gemfile ├── LICENSE ├── README.md ├── bin ├── build ├── console ├── release └── version ├── lib ├── openapi_contracts.rb └── openapi_contracts │ ├── coverage.rb │ ├── coverage │ ├── report.rb │ └── store.rb │ ├── doc.rb │ ├── doc │ ├── header.rb │ ├── operation.rb │ ├── parameter.rb │ ├── path.rb │ ├── pointer.rb │ ├── request.rb │ ├── response.rb │ ├── schema.rb │ └── with_parameters.rb │ ├── helper.rb │ ├── match.rb │ ├── operation_router.rb │ ├── parser.rb │ ├── parser │ ├── transformers.rb │ └── transformers │ │ ├── base.rb │ │ └── pointer.rb │ ├── payload_parser.rb │ ├── rspec.rb │ ├── validators.rb │ └── validators │ ├── base.rb │ ├── documented.rb │ ├── headers.rb │ ├── http_status.rb │ ├── parameters.rb │ ├── request_body.rb │ ├── response_body.rb │ └── schema_validation.rb ├── openapi_contracts.gemspec └── spec ├── .rubocop.yml ├── fixtures └── openapi │ ├── components │ ├── parameters │ │ └── messageId.yaml │ ├── responses │ │ └── BadRequest.yaml │ └── schemas │ │ ├── Address.yaml │ │ ├── Email.yaml │ │ ├── Message.yaml │ │ ├── Numbers.yaml │ │ ├── Polymorphism.yaml │ │ └── User.yaml │ ├── openapi.yaml │ ├── other.openapi.yaml │ ├── paths │ ├── comment.yaml │ ├── message.yaml │ ├── messages_last.yaml │ └── user.yaml │ └── webhooks │ └── one.yaml ├── integration └── rspec_spec.rb ├── openapi_contracts ├── coverage │ ├── report_spec.rb │ └── store_spec.rb ├── coverage_spec.rb ├── doc │ ├── parameter_spec.rb │ ├── path_spec.rb │ ├── pointer_spec.rb │ ├── request_spec.rb │ ├── response_spec.rb │ └── schema_spec.rb ├── doc_spec.rb ├── match_spec.rb ├── operation_router_spec.rb ├── payload_parser_spec.rb └── validators │ ├── documented_spec.rb │ ├── headers_spec.rb │ ├── parameters_spec.rb │ ├── request_body_spec.rb │ └── response_body_spec.rb ├── openapi_contracts_spec.rb ├── spec_helper.rb └── support ├── setup_context.rb └── test_response.rb /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Push & PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | push: 7 | branches: [main] 8 | 9 | jobs: 10 | rspec: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | activesupport: ['7.1', '7.2', '8.0'] 16 | json_schemer: ['2.1', '2.2', '2.3', '2.4'] 17 | ruby: ['3.2', '3.3', '3.4'] 18 | env: 19 | ACTIVESUPPORT: '${{ matrix.activesupport }}' 20 | COVERAGE: 'true' 21 | JSON_SCHEMER: '${{ matrix.json_schemer }}' 22 | steps: 23 | - uses: actions/checkout@v3 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: '${{ matrix.ruby }}' 28 | bundler-cache: true 29 | cache-version: ${{ matrix.activesupport }}-${{ matrix.json_schemer }} 30 | - name: Rspec 31 | run: bundle exec rspec 32 | 33 | rubocop: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Set up Ruby 38 | uses: ruby/setup-ruby@v1 39 | with: 40 | ruby-version: 3.4 41 | bundler-cache: true 42 | cache-version: '7.1' 43 | - name: Rubocop 44 | run: bundle exec rubocop 45 | 46 | openapi: 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | - name: Validate openapi fixture 51 | run: npx @redocly/openapi-cli lint spec/fixtures/openapi/openapi.yaml 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .byebug_history 2 | /coverage/ 3 | Gemfile.lock 4 | *.gem 5 | -------------------------------------------------------------------------------- /.helix/languages.toml: -------------------------------------------------------------------------------- 1 | [language-server.solargraph] 2 | config.diagnostics=false 3 | config.formatting=false 4 | 5 | [[language]] 6 | name = "ruby" 7 | auto-format = false 8 | language-servers = [ 9 | { name = "rubocop" }, 10 | { name="solargraph" } 11 | ] 12 | -------------------------------------------------------------------------------- /.redocly.lint-ignore.yaml: -------------------------------------------------------------------------------- 1 | # This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. 2 | # See https://redoc.ly/docs/cli/ for more information. 3 | spec/fixtures/openapi/openapi.yaml: 4 | operation-4xx-response: 5 | - '#/paths/~1numbers/get/responses' 6 | - '#/paths/~1pets/get/responses' 7 | security-defined: 8 | - '#/paths/~1health/get' 9 | - '#/paths/~1numbers/get' 10 | - '#/paths/~1pets/get' 11 | spec/fixtures/openapi/paths/comment.yaml: 12 | operation-4xx-response: 13 | - '#/get/responses' 14 | - '#/patch/responses' 15 | security-defined: 16 | - '#/get' 17 | - '#/patch' 18 | spec/fixtures/openapi/paths/messages_last.yaml: 19 | security-defined: 20 | - '#/get' 21 | spec/fixtures/openapi/paths/message.yaml: 22 | security-defined: 23 | - '#/get' 24 | spec/fixtures/openapi/paths/user.yaml: 25 | security-defined: 26 | - '#/get' 27 | - '#/post' 28 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | --format progress 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | TargetRubyVersion: 3.2 5 | NewCops: enable 6 | 7 | Gemspec/DevelopmentDependencies: 8 | Enabled: false 9 | 10 | Layout/HashAlignment: 11 | EnforcedColonStyle: table 12 | EnforcedHashRocketStyle: table 13 | Layout/LineLength: 14 | Max: 120 15 | Layout/SpaceInsideHashLiteralBraces: 16 | EnforcedStyle: no_space 17 | 18 | Lint/AssignmentInCondition: 19 | Enabled: false 20 | 21 | Style/BlockDelimiters: 22 | EnforcedStyle: braces_for_chaining 23 | Style/MultilineBlockChain: 24 | Enabled: false 25 | Style/ClassAndModuleChildren: 26 | Enabled: false 27 | Style/ConditionalAssignment: 28 | Enabled: false 29 | Style/Documentation: 30 | Enabled: false 31 | Style/FetchEnvVar: 32 | Enabled: false 33 | Style/FrozenStringLiteralComment: 34 | Enabled: false 35 | Style/PercentLiteralDelimiters: 36 | PreferredDelimiters: 37 | '%i': () 38 | '%w': () 39 | 40 | # Broken when trying to be compatibale with ruby < 3.3 and >= 3.3 41 | Naming/BlockForwarding: 42 | Enabled: false 43 | Style/ArgumentsForwarding: 44 | Enabled: false 45 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2022-06-02 11:51:09 UTC using RuboCop version 1.29.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 2 10 | # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. 11 | Metrics/AbcSize: 12 | Max: 27 13 | 14 | # Offense count: 3 15 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 16 | Metrics/MethodLength: 17 | Max: 15 18 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | openapi_contracts 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.1 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem 'byebug' 7 | end 8 | 9 | if (version = ENV['ACTIVESUPPORT']) 10 | gem 'activesupport', "~> #{version}.0" 11 | end 12 | if (version = ENV['JSON_SCHEMER']) 13 | gem 'json_schemer', "~> #{version}.0" 14 | end 15 | 16 | if (version = ENV['ACTIVESUPPORT']) 17 | gem 'activesupport', "~> #{version}.0" 18 | end 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Konstantin Munteanu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenapiContracts 2 | 3 | [![Push & PR](https://github.com/mkon/openapi_contracts/actions/workflows/main.yml/badge.svg)](https://github.com/mkon/openapi_contracts/actions/workflows/main.yml) 4 | [![Gem Version](https://badge.fury.io/rb/openapi_contracts.svg)](https://badge.fury.io/rb/openapi_contracts) 5 | [![Depfu](https://badges.depfu.com/badges/8ac57411497df02584bbf59685634e45/overview.svg)](https://depfu.com/github/mkon/openapi_contracts?project_id=35354) 6 | 7 | Use OpenAPI documentation as an API contract. 8 | 9 | Currently supports OpenAPI documentation in the structure as used by [Redocly](https://github.com/Redocly/create-openapi-repo), but should also work for single file schemas. 10 | 11 | Adds RSpec matchers to easily verify that your requests and responses match the OpenAPI documentation. 12 | 13 | ## Usage 14 | 15 | First, parse your API documentation: 16 | 17 | ```ruby 18 | # This must point to the folder where the OAS file is stored 19 | $doc = OpenapiContracts::Doc.parse(Rails.root.join('spec/fixtures/openapi/api-docs'), '') 20 | ``` 21 | 22 | In case the `filename` argument is not set, parser will by default search for the file named `openapi.yaml`. 23 | 24 | Ideally you do this once in an RSpec `before(:suite)` hook. Then you can use these matchers in your request specs: 25 | 26 | ```ruby 27 | subject { make_request and response } 28 | 29 | let(:make_request) { get '/some/path' } 30 | 31 | it { is_expected.to match_openapi_doc($doc) } 32 | ``` 33 | 34 | You can assert a specific http status to make sure the response is of the right status: 35 | 36 | ```ruby 37 | it { is_expected.to match_openapi_doc($doc).with_http_status(:ok) } 38 | 39 | # This is equal to 40 | it 'responds with 200 and matches the doc' do 41 | expect(subject).to have_http_status(:ok) 42 | expect(subject).to match_openapi_doc($doc) 43 | end 44 | ``` 45 | 46 | ### Options 47 | 48 | The `match_openapi_doc($doc)` method allows passing options as a 2nd argument. 49 | 50 | * `path` allows overriding the default `request.path` lookup in case it does not find the 51 | correct response definition in your schema. This is especially important when there are 52 | dynamic parameters in the path and the matcher fails to resolve the request path to 53 | an endpoint in the OAS file. 54 | 55 | ```ruby 56 | it { is_expected.to match_openapi_doc($doc, path: '/messages/{id}').with_http_status(:ok) } 57 | ``` 58 | 59 | * `request_body` can be set to `true` in case the validation of the request body against the OpenAPI _requestBody_ schema is required. 60 | 61 | ```ruby 62 | it { is_expected.to match_openapi_doc($doc, request_body: true).with_http_status(:created) } 63 | ``` 64 | 65 | * `parameters` can be set to `true` to validate request parameters against the parameter definitions 66 | 67 | ```ruby 68 | it { is_expected.to match_openapi_doc($doc, parameters: true) } 69 | ``` 70 | 71 | Both options can as well be used simultaneously. 72 | 73 | ### Without RSpec 74 | 75 | You can also use the Validator directly: 76 | 77 | ```ruby 78 | # Let's raise an error if the response does not match 79 | result = OpenapiContracts.match($doc, response, options = {}) 80 | raise result.errors.merge("/n") unless result.valid? 81 | ``` 82 | 83 | ## Coverage reporting 84 | 85 | You can generate a coverage report, giving an indication how many of your OpenApi operations and 86 | responses are verified. 87 | 88 | To enable the report, set the configuration `OpenapiContracts.collect_coverage = true`. 89 | 90 | After the tests completed, you can generate the JSON file, for example: 91 | 92 | ```ruby 93 | RSpec.configure do |c| 94 | c.after(:suite) do 95 | $your_api_doc.coverage.report.generate(Rails.root.join("openapi_coverage.json")) 96 | end 97 | end 98 | ``` 99 | 100 | In case you run tests on multiple nodes and need to merge reports: 101 | 102 | ```ruby 103 | OpenapiContracts::Coverage.merge_reports( 104 | $your_api_doc, 105 | *Dir[Rails.root.join("openapi_coverage_*.json")] 106 | ).generate(Rails.root.join("openapi_coverage.json")) 107 | 108 | ``` 109 | 110 | ## How it works 111 | 112 | It uses the `request.path`, `request.method`, `status` and `headers` on the test subject 113 | (which must be the response) to find the request and response schemas in the OpenAPI document. 114 | Then it does the following checks: 115 | 116 | * The response is documented 117 | * Required headers are present 118 | * Documented headers match the schema (via json_schemer) 119 | * The response body matches the schema (via json_schemer) 120 | * The request body matches the schema (via json_schemer) - if `request_body: true` 121 | 122 | ## Known Issues 123 | 124 | None at the moment :) 125 | 126 | ## Future plans 127 | 128 | * Validate Webmock stubs against the OpenAPI doc 129 | * Generate example payloads from the OpenAPI doc 130 | -------------------------------------------------------------------------------- /bin/build: -------------------------------------------------------------------------------- 1 | #!/bin/bash -le 2 | 3 | VERSION=`bin/version` gem build openapi_contracts.gemspec 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | irb -I ./lib -r 'bundler' -r 'rubygems' -r 'openapi_contracts' 3 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -le 2 | 3 | name="openapi_contracts" 4 | 5 | git fetch origin 6 | current=`bin/version` 7 | sha=`git rev-parse HEAD` 8 | 9 | read -p "Which version? (${current}) " version 10 | version=${version:=$current} 11 | 12 | VERSION=$version gem build ${name}.gemspec 13 | 14 | echo "Creating GitHub release" 15 | link=`gh release create v${version} --target $sha --generate-notes` 16 | echo $link 17 | git fetch --tags origin 18 | 19 | file="${name}-${version}.gem" 20 | read -p "Push to rubygems? (y/n) " yn 21 | case $yn in 22 | y ) echo Pushing to rubygems ...;; 23 | n ) echo Aborting.; 24 | exit;; 25 | * ) echo invalid response; 26 | exit 1;; 27 | esac 28 | 29 | gem push $file 30 | rm $file 31 | 32 | open $link 33 | -------------------------------------------------------------------------------- /bin/version: -------------------------------------------------------------------------------- 1 | #!/bin/bash -le 2 | 3 | branch=`git rev-parse --abbrev-ref HEAD` 4 | version=`git describe --tags --match "v*" | sed -e "s/^v//"` 5 | ref=`git rev-parse --short HEAD` 6 | 7 | # 0.0.0-i-sha or 0.0-i-sha 8 | if [[ "$version" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)-([0-9]+)-([a-z0-9]+) ]]; then 9 | minor=$((${BASH_REMATCH[3]}+1)) 10 | version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${minor}.${branch}.${BASH_REMATCH[4]}.$ref" 11 | elif [[ "$version" =~ ^([0-9]+)\.([0-9]+)-([0-9]+)-([a-z0-9]+) ]]; then 12 | minor=1 13 | version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${minor}.${branch}.${BASH_REMATCH[3]}.$ref" 14 | fi 15 | 16 | echo $version 17 | -------------------------------------------------------------------------------- /lib/openapi_contracts.rb: -------------------------------------------------------------------------------- 1 | require 'active_support' 2 | require 'active_support/core_ext/array' 3 | require 'active_support/core_ext/hash' 4 | require 'active_support/core_ext/class' 5 | require 'active_support/core_ext/module' 6 | require 'active_support/core_ext/string' 7 | require 'rubygems/version' 8 | 9 | require 'json_schemer' 10 | require 'openapi_parameters' 11 | require 'rack' 12 | require 'yaml' 13 | 14 | module OpenapiContracts 15 | autoload :Coverage, 'openapi_contracts/coverage' 16 | autoload :Doc, 'openapi_contracts/doc' 17 | autoload :Helper, 'openapi_contracts/helper' 18 | autoload :Match, 'openapi_contracts/match' 19 | autoload :OperationRouter, 'openapi_contracts/operation_router' 20 | autoload :Parser, 'openapi_contracts/parser' 21 | autoload :PayloadParser, 'openapi_contracts/payload_parser' 22 | autoload :Validators, 'openapi_contracts/validators' 23 | 24 | include ActiveSupport::Configurable 25 | 26 | Env = Struct.new(:operation, :options, :request, :response, keyword_init: true) 27 | 28 | config_accessor(:collect_coverage) { false } 29 | 30 | module_function 31 | 32 | def match(doc, response, options = {}) 33 | Match.new(doc, response, options) 34 | end 35 | 36 | def hash_bury(hash, keys, value) 37 | other = keys.reverse.reduce(value) { |m, k| {k => m} } 38 | hash.deep_merge other 39 | end 40 | 41 | def hash_bury!(hash, keys, value) 42 | other = keys.reverse.reduce(value) { |m, k| {k => m} } 43 | hash.deep_merge! other 44 | other 45 | end 46 | end 47 | 48 | require 'openapi_contracts/rspec' if defined?(RSpec) 49 | -------------------------------------------------------------------------------- /lib/openapi_contracts/coverage.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Coverage 3 | autoload :Report, 'openapi_contracts/coverage/report' 4 | autoload :Store, 'openapi_contracts/coverage/store' 5 | 6 | def self.merge_reports(doc, *others) 7 | reports = others.map { |fp| JSON(File.read(fp))['paths'] } 8 | Report.merge(doc, *reports) 9 | end 10 | 11 | attr_reader :store 12 | 13 | def initialize(doc) 14 | @store = Store.new 15 | @doc = doc 16 | end 17 | 18 | delegate :clear!, :data, :increment!, to: :store 19 | 20 | def report 21 | Report.new(@doc, store.data) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/openapi_contracts/coverage/report.rb: -------------------------------------------------------------------------------- 1 | class OpenapiContracts::Coverage 2 | class Report 3 | def self.merge(doc, *reports) 4 | reports.each_with_object(Report.new(doc)) do |r, m| 5 | m.merge!(r) 6 | end 7 | end 8 | 9 | attr_reader :data 10 | 11 | def as_json(*) 12 | report 13 | end 14 | 15 | def initialize(doc, data = {}) 16 | @doc = doc 17 | @data = data 18 | end 19 | 20 | def generate(pathname) 21 | File.write(pathname, JSON.pretty_generate(report)) 22 | end 23 | 24 | def merge!(data) 25 | @data.deep_merge!(data) { |_key, val1, val2| val1 + val2 } 26 | end 27 | 28 | def meta 29 | { 30 | 'operations' => { 31 | 'covered' => total_covered_operations, 32 | 'total' => @doc.operations.count 33 | }, 34 | 'responses' => { 35 | 'covered' => total_covered_responses, 36 | 'total' => @doc.responses.count 37 | } 38 | }.tap do |d| 39 | d['operations']['quota'] = d['operations']['covered'].to_f / d['operations']['total'] 40 | d['responses']['quota'] = d['responses']['covered'].to_f / d['responses']['total'] 41 | end 42 | end 43 | 44 | private 45 | 46 | def report 47 | { 48 | 'meta' => meta, 49 | 'paths' => @data 50 | } 51 | end 52 | 53 | def total_covered_operations 54 | @doc.operations.select { |o| @data.dig(o.path.to_s, o.verb).present? }.count 55 | end 56 | 57 | def total_covered_responses 58 | @doc.responses.select { |r| 59 | @data.dig(r.operation.path.to_s, r.operation.verb, r.status).present? 60 | }.count 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /lib/openapi_contracts/coverage/store.rb: -------------------------------------------------------------------------------- 1 | class OpenapiContracts::Coverage 2 | class Store 3 | attr_accessor :data 4 | 5 | def initialize 6 | @data = {} 7 | end 8 | 9 | def clear! 10 | @data = {} 11 | end 12 | 13 | def increment!(path, method, status, media_type) 14 | keys = [path, method, status] 15 | val = @data.dig(*keys) || Hash.new(0).tap { |h| OpenapiContracts.hash_bury!(@data, keys, h) } 16 | val[media_type] += 1 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc 3 | autoload :Header, 'openapi_contracts/doc/header' 4 | autoload :Operation, 'openapi_contracts/doc/operation' 5 | autoload :Parameter, 'openapi_contracts/doc/parameter' 6 | autoload :Path, 'openapi_contracts/doc/path' 7 | autoload :Pointer, 'openapi_contracts/doc/pointer' 8 | autoload :Request, 'openapi_contracts/doc/request' 9 | autoload :Response, 'openapi_contracts/doc/response' 10 | autoload :Schema, 'openapi_contracts/doc/schema' 11 | autoload :WithParameters, 'openapi_contracts/doc/with_parameters' 12 | 13 | def self.parse(dir, filename = 'openapi.yaml') 14 | new Parser.call(dir, filename) 15 | end 16 | 17 | attr_reader :coverage, :schema 18 | 19 | def initialize(raw) 20 | @schema = Schema.new(raw) 21 | @paths = @schema['paths'].to_h do |path, _| 22 | [path, Path.new(path, @schema.at_pointer(Doc::Pointer['paths', path]))] 23 | end 24 | @dynamic_paths = paths.select(&:dynamic?) 25 | @coverage = Coverage.new(self) 26 | end 27 | 28 | # Returns an Enumerator over all paths 29 | def paths 30 | @paths.each_value 31 | end 32 | 33 | def operation_for(path, method) 34 | OperationRouter.new(self).route(path, method.downcase) 35 | end 36 | 37 | # Returns an Enumerator over all Operations 38 | def operations(&block) 39 | return enum_for(:operations) unless block_given? 40 | 41 | paths.each do |path| 42 | path.operations.each(&block) 43 | end 44 | end 45 | 46 | # Returns an Enumerator over all Responses 47 | def responses(&block) 48 | return enum_for(:responses) unless block_given? 49 | 50 | operations.each do |operation| 51 | operation.responses.each(&block) 52 | end 53 | end 54 | 55 | def with_path(path) 56 | @paths[path] 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/header.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Header 3 | attr_reader :name 4 | 5 | def initialize(name, data) 6 | @name = name 7 | @data = data 8 | end 9 | 10 | def required? 11 | @data['required'] 12 | end 13 | 14 | def schema 15 | @data['schema'] 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/operation.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Operation 3 | include Doc::WithParameters 4 | 5 | attr_reader :path 6 | 7 | def initialize(path, spec) 8 | @path = path 9 | @spec = spec 10 | @responses = spec.navigate('responses').each.to_h do |status, subspec| 11 | [status, Doc::Response.new(self, status, subspec)] 12 | end 13 | end 14 | 15 | def verb 16 | @spec.pointer[2] 17 | end 18 | 19 | def request_body 20 | return @request_body if instance_variable_defined?(:@request_body) 21 | 22 | @request_body = @spec.navigate('requestBody').presence&.then { |s| Doc::Request.new(s) } 23 | end 24 | 25 | def responses 26 | @responses.each_value 27 | end 28 | 29 | def response_for_status(status) 30 | @responses[status.to_s] 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/parameter.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Parameter 3 | attr_reader :name, :in, :schema 4 | 5 | def initialize(spec) 6 | @spec = spec 7 | options = spec.to_h 8 | @name = options['name'] 9 | @in = options['in'] 10 | @required = options['required'] 11 | end 12 | 13 | def in_path? 14 | @in == 'path' 15 | end 16 | 17 | def in_query? 18 | @in == 'query' 19 | end 20 | 21 | def matches?(value) 22 | errors = schemer.validate(convert_value(value)) 23 | # debug errors.to_a here 24 | errors.none? 25 | end 26 | 27 | def required? 28 | @required == true 29 | end 30 | 31 | def schema_for_validation 32 | @spec.navigate('schema') 33 | end 34 | 35 | private 36 | 37 | def convert_value(original) 38 | OpenapiParameters::Converter.convert(original, schema_for_validation) 39 | rescue StandardError 40 | original 41 | end 42 | 43 | def schemer 44 | @schemer ||= Validators::SchemaValidation.validation_schemer(schema_for_validation) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/path.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Path 3 | include Doc::WithParameters 4 | 5 | HTTP_METHODS = %w(get head post put delete connect options trace patch).freeze 6 | 7 | attr_reader :path 8 | 9 | def initialize(path, spec) 10 | @path = path 11 | @spec = spec 12 | @supported_methods = HTTP_METHODS & @spec.keys 13 | @operations = @supported_methods.to_h do |verb| 14 | [verb, Doc::Operation.new(self, @spec.navigate(verb))] 15 | end 16 | end 17 | 18 | def dynamic? 19 | @path.include?('{') 20 | end 21 | 22 | def operations 23 | @operations.each_value 24 | end 25 | 26 | def path_regexp 27 | @path_regexp ||= begin 28 | re = /\{([^\}]+)\}/ 29 | @path.gsub(re) { |placeholder| 30 | placeholder.match(re) { |m| "(?<#{m[1]}>[^/]*)" } 31 | }.then { |str| Regexp.new("^#{str}$") } 32 | end 33 | end 34 | 35 | def static? 36 | !dynamic? 37 | end 38 | 39 | def supports_method?(method) 40 | @operations.key?(method) 41 | end 42 | 43 | def to_s 44 | @path 45 | end 46 | 47 | def with_method(method) 48 | @operations[method] 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/pointer.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Pointer 3 | def self.[](*segments) 4 | new Array.wrap(segments).flatten 5 | end 6 | 7 | def self.from_json_pointer(str) 8 | raise ArguementError unless %r{^#/(?.*)} =~ str 9 | 10 | new(pointer.split('/').map { |s| s.gsub('~1', '/') }) 11 | end 12 | 13 | def self.from_path(pathname) 14 | new pathname.to_s.split('/') 15 | end 16 | 17 | def initialize(segments) 18 | @segments = segments 19 | end 20 | 21 | def inspect 22 | "<#{self.class.name}#{to_a}>" 23 | end 24 | 25 | delegate :any?, :empty?, :[], to: :@segments 26 | 27 | def navigate(*segments) 28 | self.class[to_a + segments] 29 | end 30 | 31 | def parent 32 | self.class[to_a[0..-2]] 33 | end 34 | 35 | def to_a 36 | @segments 37 | end 38 | 39 | def to_json_pointer 40 | escaped_segments.join('/').then { |s| "#/#{s}" } 41 | end 42 | 43 | def to_json_schemer_pointer 44 | www_escaped_segments.join('/').then { |s| "#/#{s}" } 45 | end 46 | 47 | def walk(object) 48 | return object if empty? 49 | 50 | @segments.inject(object) do |obj, key| 51 | return nil unless obj 52 | 53 | if obj.is_a?(Array) 54 | raise ArgumentError unless /^\d+$/ =~ key 55 | 56 | key = key.to_i 57 | end 58 | 59 | obj[key] 60 | end 61 | end 62 | 63 | def ==(other) 64 | to_a == other.to_a 65 | end 66 | 67 | private 68 | 69 | def escaped_segments 70 | @segments.map do |s| 71 | s.gsub(%r{/}, '~1') 72 | end 73 | end 74 | 75 | def www_escaped_segments 76 | escaped_segments.map do |s| 77 | URI.encode_www_form_component(s) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/request.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Request 3 | def initialize(schema) 4 | @schema = schema.follow_refs 5 | end 6 | 7 | def schema_for(media_type) 8 | return unless supports_media_type?(media_type) 9 | 10 | @schema.navigate('content', media_type, 'schema') 11 | end 12 | 13 | def supports_media_type?(media_type) 14 | @schema.dig('content', media_type).present? 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/response.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc::Response 3 | attr_reader :coverage, :schema, :status, :operation 4 | 5 | delegate :pointer, to: :schema 6 | 7 | def initialize(operation, status, schema) 8 | @operation = operation 9 | @status = status 10 | @schema = schema.follow_refs 11 | end 12 | 13 | def headers 14 | return @headers if instance_variable_defined? :@headers 15 | 16 | @headers = @schema.fetch('headers', {}).map do |(key, val)| 17 | Doc::Header.new(key, val) 18 | end 19 | end 20 | 21 | def schema_for(media_type) 22 | return unless supports_media_type?(media_type) 23 | 24 | @schema.navigate('content', media_type, 'schema') 25 | end 26 | 27 | def no_content? 28 | !@schema.key? 'content' 29 | end 30 | 31 | def supports_media_type?(media_type) 32 | @schema.dig('content', media_type).present? 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/schema.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | # Represents a part or the whole schema 3 | # Generally even parts of the schema contain the whole schema, but store the pointer to 4 | # their position in the overall schema. This allows even small sub-schemas to resolve 5 | # links to any other part of the schema 6 | class Doc::Schema 7 | attr_reader :pointer, :raw 8 | 9 | def initialize(raw, pointer = Doc::Pointer[]) 10 | raise ArgumentError unless pointer.is_a?(Doc::Pointer) 11 | 12 | @raw = raw 13 | @pointer = pointer.freeze 14 | end 15 | 16 | def each # rubocop:disable Metrics/MethodLength 17 | data = resolve 18 | case data 19 | when Array 20 | enum = data.each_with_index 21 | Enumerator.new(enum.size) do |yielder| 22 | loop do 23 | _item, index = enum.next 24 | yielder << navigate(index.to_s) 25 | end 26 | end 27 | when Hash 28 | enum = data.each_key 29 | Enumerator.new(enum.size) do |yielder| 30 | loop do 31 | key = enum.next 32 | yielder << [key, navigate(key)] 33 | end 34 | end 35 | end 36 | end 37 | 38 | # :nocov: 39 | def inspect 40 | "<#{self.class.name} @pointer=#{@pointer.inspect}>" 41 | end 42 | # :nocov: 43 | 44 | # Resolves Schema ref pointers links like "$ref: #/some/path" and returns new sub-schema 45 | # at the target if the current schema is only a ref link. 46 | def follow_refs 47 | data = resolve 48 | if data.is_a?(Hash) && data.key?('$ref') 49 | at_pointer Doc::Pointer.from_json_pointer(data['$ref']) 50 | else 51 | self 52 | end 53 | end 54 | 55 | # Generates a fragment pointer for the current schema path 56 | def fragment 57 | pointer.to_json_schemer_pointer 58 | end 59 | 60 | delegate :dig, to: :resolve, allow_nil: true 61 | delegate :fetch, :keys, :key?, :[], :to_h, to: :resolve 62 | 63 | def at_pointer(pointer) 64 | self.class.new(raw, pointer) 65 | end 66 | 67 | def openapi_version 68 | @raw['openapi']&.then { |v| Gem::Version.new(v) } 69 | end 70 | 71 | def presence 72 | resolve.present? ? self : nil 73 | end 74 | 75 | # Returns the actual sub-specification contents at the pointer of this Specification 76 | def resolve 77 | @pointer.walk(@raw) 78 | end 79 | 80 | def navigate(*segments) 81 | self.class.new(@raw, pointer.navigate(segments)).follow_refs 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/openapi_contracts/doc/with_parameters.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Doc 3 | module WithParameters 4 | def parameters 5 | @parameters ||= Array.wrap(@spec.navigate('parameters')&.each&.map { |s| Doc::Parameter.new(s) }) 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/openapi_contracts/helper.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | module Helper 3 | def http_status_desc(status) 4 | "http status #{Rack::Utils::HTTP_STATUS_CODES[status]} (#{status})" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/openapi_contracts/match.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Match 3 | DEFAULT_OPTIONS = { 4 | parameters: false, 5 | request_body: false 6 | }.freeze 7 | MIN_REQUEST_ANCESTORS = %w(Rack::Request::Env Rack::Request::Helpers).freeze 8 | MIN_RESPONSE_ANCESTORS = %w(Rack::Response::Helpers).freeze 9 | 10 | attr_reader :errors 11 | 12 | def initialize(doc, response, options = {}) 13 | @doc = doc 14 | @response = response 15 | @request = options.delete(:request) { response.request } 16 | @options = DEFAULT_OPTIONS.merge(options) 17 | raise ArgumentError, "#{@response} must be compatible with Rack::Response::Helpers" unless response_compatible? 18 | raise ArgumentError, "#{@request} must be compatible with Rack::Request::{Env,Helpers}" unless request_compatible? 19 | end 20 | 21 | def valid? 22 | return @errors.empty? if instance_variable_defined?(:@errors) 23 | 24 | @errors = matchers.call 25 | @doc.coverage.increment!(operation.path.to_s, request_method, status, media_type) if collect_coverage? 26 | @errors.empty? 27 | end 28 | 29 | private 30 | 31 | def collect_coverage? 32 | OpenapiContracts.collect_coverage && @request.present? && @errors.empty? && !@options[:nocov] 33 | end 34 | 35 | def media_type 36 | @response.headers['Content-Type']&.split(';')&.first || 'no_content' 37 | end 38 | 39 | def matchers 40 | env = Env.new( 41 | options: @options, 42 | operation:, 43 | request: @request, 44 | response: @response 45 | ) 46 | validators = Validators::ALL.dup 47 | validators.delete(Validators::HttpStatus) unless @options[:status] 48 | validators.delete(Validators::Parameters) unless @options[:parameters] 49 | validators.delete(Validators::RequestBody) unless @options[:request_body] 50 | validators.reverse 51 | .reduce(->(err) { err }) { |s, m| m.new(s, env) } 52 | end 53 | 54 | def operation 55 | @operation ||= @doc.operation_for(path, request_method) 56 | end 57 | 58 | def request_compatible? 59 | ancestors = @request.class.ancestors.map(&:to_s) 60 | MIN_REQUEST_ANCESTORS.all? { |s| ancestors.include?(s) } 61 | end 62 | 63 | def response_compatible? 64 | ancestors = @response.class.ancestors.map(&:to_s) 65 | MIN_RESPONSE_ANCESTORS.all? { |s| ancestors.include?(s) } 66 | end 67 | 68 | def request_method 69 | @request.request_method.downcase 70 | end 71 | 72 | def path 73 | @options.fetch(:path, @request.path) 74 | end 75 | 76 | def status 77 | @response.status.to_s 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/openapi_contracts/operation_router.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class OperationRouter 3 | def initialize(doc) 4 | @doc = doc 5 | @dynamic_paths = doc.paths.select(&:dynamic?) 6 | end 7 | 8 | def route(actual_path, method) 9 | @doc.with_path(actual_path)&.then { |p| return p.with_method(method) } 10 | 11 | @dynamic_paths.each do |path| 12 | next unless path.supports_method?(method) 13 | next unless m = path.path_regexp.match(actual_path) 14 | 15 | operation = path.with_method(method) 16 | parameters = (path.parameters + operation.parameters).select(&:in_path?) 17 | 18 | return operation if parameter_match?(m.named_captures, parameters) 19 | end 20 | 21 | nil 22 | end 23 | 24 | private 25 | 26 | def parameter_match?(actual_params, parameters) 27 | actual_params.each do |k, v| 28 | return false unless parameters&.find { |s| s.name == k }&.matches?(v) 29 | end 30 | true 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/openapi_contracts/parser.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | class Parser 3 | autoload :Transformers, 'openapi_contracts/parser/transformers' 4 | 5 | TRANSFORMERS = [Transformers::Pointer].freeze 6 | 7 | def self.call(dir, filename) 8 | new(dir.join(filename)).parse 9 | end 10 | 11 | attr_reader :filenesting, :rootfile 12 | 13 | def initialize(rootfile) 14 | @cwd = rootfile.parent 15 | @rootfile = rootfile 16 | @filenesting = {} 17 | end 18 | 19 | def parse 20 | @filenesting = build_file_list 21 | @filenesting.each_with_object({}) do |(path, pointer), schema| 22 | target = pointer.to_a.reduce(schema) { |d, k| d[k] ||= {} } 23 | target.delete('$ref') # ref file pointers should be in the file list so save to delete 24 | target.merge! file_to_data(path, pointer) 25 | end 26 | end 27 | 28 | private 29 | 30 | # file list consists of 31 | # - root file 32 | # - all files in components/ 33 | # - all path & webhook files referenced by the root file 34 | def build_file_list 35 | list = {@rootfile.relative_path_from(@cwd) => Doc::Pointer[]} 36 | Dir[File.expand_path('components/**/*.yaml', @cwd)].each do |file| 37 | pathname = Pathname(file).relative_path_from(@cwd).cleanpath 38 | pointer = Doc::Pointer.from_path pathname.sub_ext('') 39 | list.merge! pathname => pointer 40 | end 41 | rootdata = YAML.safe_load_file(@rootfile) 42 | %w(paths webhooks).each do |name| 43 | rootdata.fetch(name) { {} }.each_pair do |k, v| 44 | next unless v['$ref'] && !v['$ref'].start_with?('#') 45 | 46 | list.merge! Pathname(v['$ref']).cleanpath => Doc::Pointer[name, k] 47 | end 48 | end 49 | list 50 | end 51 | 52 | def file_to_data(pathname, pointer) 53 | YAML.safe_load_file(@cwd.join(pathname)).tap do |data| 54 | break {} unless data.present? 55 | 56 | transform_objects!(data, pathname.parent, pointer) 57 | end 58 | end 59 | 60 | def transform_objects!(object, cwd, pointer) 61 | case object 62 | when Hash 63 | object.each_value { |v| transform_objects!(v, cwd, pointer) } 64 | TRANSFORMERS.map { |t| t.new(self, cwd, pointer) }.each { |t| t.call(object) } 65 | when Array 66 | object.each { |o| transform_objects!(o, cwd, pointer) } 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/openapi_contracts/parser/transformers.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Parser::Transformers 2 | autoload :Base, 'openapi_contracts/parser/transformers/base' 3 | autoload :Pointer, 'openapi_contracts/parser/transformers/pointer' 4 | end 5 | -------------------------------------------------------------------------------- /lib/openapi_contracts/parser/transformers/base.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Parser::Transformers 2 | class Base 3 | def initialize(parser, cwd, pointer) 4 | @parser = parser 5 | @cwd = cwd 6 | @pointer = pointer 7 | end 8 | 9 | # :nocov: 10 | def call 11 | raise NotImplementedError 12 | end 13 | # :nocov: 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/openapi_contracts/parser/transformers/pointer.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Parser::Transformers 2 | class Pointer < Base 3 | def call(object) 4 | transform_discriminator(object) 5 | transform_refs(object) 6 | end 7 | 8 | private 9 | 10 | def transform_discriminator(object) 11 | mappings = object.dig('discriminator', 'mapping') 12 | return unless mappings.present? 13 | 14 | mappings.transform_values! { |p| transform_pointer(p) } 15 | end 16 | 17 | def transform_refs(object) 18 | return unless object['$ref'].present? 19 | 20 | object['$ref'] = transform_pointer(object['$ref']) 21 | end 22 | 23 | def transform_pointer(target) 24 | if %r{^#/(?.*)} =~ target 25 | # A JSON Pointer 26 | generate_absolute_pointer(pointer) 27 | elsif %r{^(?[^#]+)(?:#/(?.*))?} =~ target 28 | ptr = @parser.filenesting[@cwd.join(relpath).cleanpath] 29 | raise "Unknown file: #{@cwd.join(relpath).cleanpath.inspect}" unless ptr 30 | 31 | tgt = ptr.to_json_pointer 32 | tgt += "/#{pointer}" if pointer 33 | tgt 34 | else 35 | target 36 | end 37 | end 38 | 39 | # A JSON pointer to the currently parsed file as seen from the root openapi file 40 | def generate_absolute_pointer(json_pointer) 41 | if @pointer.empty? 42 | "#/#{json_pointer}" 43 | else 44 | "#{@pointer.to_json_pointer}/#{json_pointer}" 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/openapi_contracts/payload_parser.rb: -------------------------------------------------------------------------------- 1 | require 'singleton' 2 | 3 | module OpenapiContracts 4 | class PayloadParser 5 | include Singleton 6 | 7 | class << self 8 | delegate :parse, :register, to: :instance 9 | end 10 | 11 | Entry = Struct.new(:matcher, :parser) do 12 | def call(raw) 13 | parser.call(raw) 14 | end 15 | 16 | def match?(media_type) 17 | matcher == media_type || matcher.match?(media_type) 18 | end 19 | end 20 | 21 | def initialize 22 | @parsers = [] 23 | end 24 | 25 | def parse(media_type, payload) 26 | parser = @parsers.find { |e| e.match?(media_type) } 27 | raise ArgumentError, "#{media_type.inspect} is not supported yet" unless parser 28 | 29 | parser.call(payload) 30 | end 31 | 32 | def register(matcher, parser) 33 | @parsers << Entry.new(matcher, parser) 34 | end 35 | end 36 | 37 | PayloadParser.register(%r{(/|\+)json$}, ->(raw) { JSON(raw) }) 38 | PayloadParser.register('application/x-www-form-urlencoded', ->(raw) { Rack::Utils.parse_nested_query(raw) }) 39 | PayloadParser.register(%r{^text/}, ->(raw) { raw }) 40 | end 41 | -------------------------------------------------------------------------------- /lib/openapi_contracts/rspec.rb: -------------------------------------------------------------------------------- 1 | require 'rspec/matchers' 2 | 3 | RSpec::Matchers.define :match_openapi_doc do |doc, options = {}| # rubocop:disable Metrics/BlockLength 4 | include OpenapiContracts::Helper 5 | 6 | match do |response| 7 | match = OpenapiContracts::Match.new( 8 | doc, 9 | response, 10 | options.merge({status: @status}.compact) 11 | ) 12 | return true if match.valid? 13 | 14 | @errors = match.errors 15 | false 16 | end 17 | 18 | description do 19 | desc = 'match the openapi schema' 20 | desc << " with #{http_status_desc(@status)}" if @status 21 | desc 22 | end 23 | 24 | # :nocov: 25 | failure_message do |_response| 26 | @errors.map { |e| "* #{e}" }.join("\n") 27 | end 28 | # :nocov: 29 | 30 | def with_http_status(status) 31 | if status.is_a? Symbol 32 | @status = Rack::Utils::SYMBOL_TO_STATUS_CODE[status] 33 | else 34 | @status = status 35 | end 36 | self 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts 2 | module Validators 3 | autoload :Base, 'openapi_contracts/validators/base' 4 | autoload :Documented, 'openapi_contracts/validators/documented' 5 | autoload :Headers, 'openapi_contracts/validators/headers' 6 | autoload :HttpStatus, 'openapi_contracts/validators/http_status' 7 | autoload :Parameters, 'openapi_contracts/validators/parameters' 8 | autoload :RequestBody, 'openapi_contracts/validators/request_body' 9 | autoload :ResponseBody, 'openapi_contracts/validators/response_body' 10 | autoload :SchemaValidation, 'openapi_contracts/validators/schema_validation' 11 | 12 | # Defines order of matching 13 | ALL = [ 14 | Documented, 15 | HttpStatus, 16 | Parameters, 17 | RequestBody, 18 | ResponseBody, 19 | Headers 20 | ].freeze 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/base.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | class Base 3 | include OpenapiContracts::Helper 4 | 5 | class_attribute :final, instance_writer: false 6 | self.final = false 7 | 8 | def initialize(stack, env) 9 | @stack = stack # next matcher 10 | @env = env 11 | @errors = [] 12 | end 13 | 14 | def call(errors = []) 15 | validate 16 | errors.push(*@errors) 17 | # Do not call the next matcher when there is errors on a final matcher 18 | return errors if @errors.any? && final? 19 | 20 | @stack.call(errors) 21 | end 22 | 23 | private 24 | 25 | delegate :operation, :options, :request, :response, to: :@env 26 | 27 | def response_desc 28 | "#{request.request_method} #{request.path}" 29 | end 30 | 31 | # :nocov: 32 | def validate 33 | raise NotImplementedError 34 | end 35 | # :nocov: 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/documented.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | # Purpose of this validator 3 | # * ensure the operation is documented (combination http-method + path) 4 | # * ensure the response-status is documented on the operation 5 | class Documented < Base 6 | self.final = true 7 | 8 | private 9 | 10 | def validate 11 | return operation_missing unless operation 12 | 13 | response_missing unless operation.response_for_status(response.status) 14 | end 15 | 16 | def operation_missing 17 | @errors << "Undocumented operation for #{response_desc.inspect}" 18 | end 19 | 20 | def response_missing 21 | status_desc = http_status_desc(response.status) 22 | @errors << "Undocumented response for #{response_desc.inspect} with #{status_desc}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/headers.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | class Headers < Base 3 | private 4 | 5 | def spec 6 | @spec ||= operation.response_for_status(response.status) 7 | end 8 | 9 | def validate 10 | spec.headers.each do |header| 11 | value = response.headers[header.name] 12 | 13 | # Rack 3.0.0 returns an Array for multi-value headers. OpenAPI doesn't 14 | # support multi-value headers, so we join them into a single string. 15 | # 16 | # @see https://github.com/rack/rack/issues/1598 17 | value = value.join("\n") if value.is_a?(Array) 18 | 19 | if value.blank? 20 | @errors << "Missing header #{header.name}" if header.required? 21 | else 22 | schemer = JSONSchemer.schema(header.schema) 23 | unless schemer.valid?(value) 24 | validation_errors = schemer.validate(value).to_a 25 | @errors << validation_error_message(header, value, validation_errors) 26 | end 27 | end 28 | end 29 | end 30 | 31 | def validation_error_message(header, value, error_array) 32 | if error_array.empty? 33 | "Header #{header.name} does not match schema" 34 | else 35 | error_array.map { |error| 36 | error_message = "Header #{header.name} validation error: #{error['error']}" 37 | truncated_value = value.to_s.truncate(100) 38 | error_message + " (value: #{truncated_value})" 39 | }.join("\n") 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/http_status.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | class HttpStatus < Base 3 | self.final = true 4 | 5 | private 6 | 7 | def validate 8 | return if options[:status] == response.status 9 | 10 | @errors << "Response has #{http_status_desc(response.status)}" 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/parameters.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | # Validates the input parameters, eg path/url parameters 3 | class Parameters < Base 4 | include SchemaValidation 5 | 6 | private 7 | 8 | def validate 9 | operation.parameters.select(&:in_query?).each do |parameter| 10 | if request.GET.key?(parameter.name) 11 | value = request.GET[parameter.name] 12 | unless parameter.matches?(value) 13 | @errors << "#{value.inspect} is not a valid value for the query parameter #{parameter.name.inspect}" 14 | end 15 | elsif parameter.required? 16 | @errors << "Missing query parameter #{parameter.name.inspect}" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/request_body.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | class RequestBody < Base 3 | include SchemaValidation 4 | 5 | private 6 | 7 | delegate :media_type, to: :request 8 | delegate :request_body, to: :operation 9 | 10 | def data_for_validation 11 | request.body.rewind 12 | raw = request.body.read 13 | OpenapiContracts::PayloadParser.parse(media_type, raw) 14 | end 15 | 16 | def validate 17 | if !request_body 18 | @errors << "Undocumented request body for #{response_desc.inspect}" 19 | elsif !request_body.supports_media_type?(media_type) 20 | @errors << "Undocumented request with media-type #{media_type.inspect}" 21 | else 22 | @errors += validate_schema(request_body.schema_for(media_type), data_for_validation) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/response_body.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | class ResponseBody < Base 3 | include SchemaValidation 4 | 5 | private 6 | 7 | delegate :media_type, to: :response 8 | 9 | def data_for_validation 10 | # ActionDispatch::Response body is a plain string, while Rack::Response returns an array 11 | OpenapiContracts::PayloadParser.parse(media_type, Array.wrap(response.body).join) 12 | end 13 | 14 | def spec 15 | @spec ||= operation.response_for_status(response.status) 16 | end 17 | 18 | def validate 19 | if spec.no_content? 20 | @errors << 'Expected empty response body' if Array.wrap(response.body).any?(&:present?) 21 | elsif !spec.supports_media_type?(media_type) 22 | @errors << "Undocumented response with content-type #{media_type.inspect}" 23 | else 24 | @errors += validate_schema(spec.schema_for(media_type), data_for_validation) 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/openapi_contracts/validators/schema_validation.rb: -------------------------------------------------------------------------------- 1 | module OpenapiContracts::Validators 2 | module SchemaValidation 3 | module_function 4 | 5 | def error_to_message(error) 6 | msg = error['error'] 7 | msg.sub!(/^value/, error['data'].to_json) if error['data'].to_json.length < 50 8 | msg 9 | end 10 | 11 | def schema_draft_version(schema) 12 | if schema.openapi_version.blank? || schema.openapi_version < Gem::Version.new('3.1') 13 | JSONSchemer.openapi30 14 | else 15 | JSONSchemer.openapi31 16 | end 17 | end 18 | 19 | def validation_schemer(schema) 20 | schemer = JSONSchemer.schema(schema.raw, meta_schema: schema_draft_version(schema)) 21 | if schema.pointer.any? 22 | schemer.ref(schema.fragment) 23 | else 24 | schemer 25 | end 26 | end 27 | 28 | def validate_schema(schema, data) 29 | validation_schemer(schema).validate(data).map do |err| 30 | error_to_message(err) 31 | end 32 | rescue JSONSchemer::UnknownRef => e 33 | # This usually happens when discriminators encounter unknown types 34 | ["Could not resolve pointer #{e.message.inspect}"] 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /openapi_contracts.gemspec: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.push File.expand_path('lib', __dir__) 2 | 3 | # Describe your gem and declare its dependencies: 4 | Gem::Specification.new do |s| 5 | s.name = 'openapi_contracts' 6 | s.version = ENV.fetch 'VERSION', '0.14.0' 7 | s.authors = ['mkon'] 8 | s.email = ['konstantin@munteanu.de'] 9 | s.homepage = 'https://github.com/mkon/openapi_contracts' 10 | s.summary = 'Openapi schemas as API contracts' 11 | s.license = 'MIT' 12 | s.required_ruby_version = '>= 3.2', '< 3.5' 13 | 14 | s.files = Dir['lib/**/*', 'README.md'] 15 | 16 | s.add_dependency 'activesupport', '>= 6.1', '< 8.1' 17 | s.add_dependency 'json_schemer', '>= 2.1', '< 2.5' 18 | s.add_dependency 'openapi_parameters', '>= 0.3.3', '< 0.4' 19 | s.add_dependency 'rack', '>= 2.0.0' 20 | 21 | s.add_development_dependency 'json_spec', '~> 1.1.5' 22 | s.add_development_dependency 'rspec', '~> 3.13.0' 23 | s.add_development_dependency 'rubocop', '1.64.1' 24 | s.add_development_dependency 'rubocop-rspec', '2.31.0' 25 | s.add_development_dependency 'simplecov', '~> 0.22.0' 26 | s.metadata['rubygems_mfa_required'] = 'true' 27 | end 28 | -------------------------------------------------------------------------------- /spec/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - ../.rubocop.yml 3 | 4 | require: rubocop-rspec 5 | 6 | Metrics/BlockLength: 7 | Enabled: false 8 | 9 | RSpec/DescribeClass: 10 | Exclude: 11 | - integration/**/* 12 | RSpec/ExampleLength: 13 | Max: 10 14 | CountAsOne: 15 | - hash 16 | RSpec/IndexedLet: 17 | Enabled: false 18 | RSpec/MultipleExpectations: 19 | Enabled: false 20 | RSpec/MultipleMemoizedHelpers: 21 | Enabled: false 22 | RSpec/NamedSubject: 23 | Enabled: false 24 | RSpec/NestedGroups: 25 | Enabled: false 26 | RSpec/NotToNot: 27 | EnforcedStyle: to_not 28 | 29 | 30 | Style/BlockDelimiters: 31 | Enabled: false 32 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/parameters/messageId.yaml: -------------------------------------------------------------------------------- 1 | name: id 2 | in: path 3 | description: Id of the message. 4 | required: true 5 | schema: 6 | type: string 7 | pattern: '^[a-f,0-9]{8}$' 8 | minLength: 8 9 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/responses/BadRequest.yaml: -------------------------------------------------------------------------------- 1 | description: Bad Request 2 | content: 3 | application/json: 4 | schema: 5 | type: object 6 | properties: 7 | errors: 8 | type: array 9 | items: 10 | type: object 11 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/Address.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | properties: 3 | street: 4 | type: string 5 | city: 6 | type: string 7 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/Email.yaml: -------------------------------------------------------------------------------- 1 | type: string 2 | example: someone@host.example 3 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/Message.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | properties: 3 | id: 4 | type: string 5 | example: acd3751 6 | type: 7 | type: string 8 | attributes: 9 | type: object 10 | properties: 11 | author: 12 | type: 13 | - object 14 | - null 15 | body: 16 | type: string 17 | title: 18 | type: 19 | - string 20 | - null 21 | additionalProperties: false 22 | required: 23 | - body 24 | additionalProperties: false 25 | required: 26 | - id 27 | - type 28 | - attributes 29 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/Numbers.yaml: -------------------------------------------------------------------------------- 1 | One: 2 | type: object 3 | properties: 4 | one: 5 | type: string 6 | 7 | Two: 8 | type: object 9 | properties: 10 | two: 11 | type: string 12 | 13 | Three: 14 | type: object 15 | properties: 16 | three: 17 | type: string 18 | 19 | All: 20 | allOf: 21 | - $ref: '#/One' 22 | - $ref: '#/Two' 23 | - $ref: '#/Three' 24 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/Polymorphism.yaml: -------------------------------------------------------------------------------- 1 | Pet: 2 | type: object 3 | discriminator: 4 | propertyName: type 5 | mapping: 6 | dog: '#/Dog' 7 | cat: '#/Cat' 8 | 9 | Animal: 10 | properties: 11 | type: 12 | type: string 13 | enum: [cat, dog] 14 | age: 15 | type: number 16 | required: 17 | - age 18 | - type 19 | 20 | Cat: 21 | description: A cat 22 | allOf: 23 | - $ref: '#/Animal' 24 | unevaluatedProperties: false 25 | 26 | Dog: 27 | description: A dog 28 | allOf: 29 | - $ref: '#/Animal' 30 | - type: object 31 | properties: 32 | leash: 33 | type: boolean 34 | required: [leash] 35 | unevaluatedProperties: false 36 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/components/schemas/User.yaml: -------------------------------------------------------------------------------- 1 | type: object 2 | properties: 3 | id: 4 | type: string 5 | example: acd3751 6 | type: 7 | type: string 8 | attributes: 9 | type: object 10 | properties: 11 | name: 12 | type: 13 | - string 14 | - null 15 | addresses: 16 | oneOf: 17 | - type: array 18 | oneOf: 19 | - $ref: './Address.yaml' 20 | - type: "null" 21 | email: 22 | $ref: './Email.yaml' 23 | rank: 24 | oneOf: 25 | - type: "null" 26 | - type: number 27 | format: float 28 | additionalProperties: false 29 | required: 30 | - name 31 | - email 32 | additionalProperties: false 33 | required: 34 | - id 35 | - type 36 | - attributes 37 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | version: 1.0.0 4 | title: Example.com 5 | termsOfService: 'https://example.com/terms/' 6 | contact: 7 | email: contact@example.com 8 | url: 'https://example.com/contact' 9 | license: 10 | name: Apache 2.0 11 | url: 'https://www.apache.org/licenses/LICENSE-2.0.html' 12 | x-logo: 13 | url: 'https://redocly.github.io/openapi-template/logo.png' 14 | description: > 15 | This is an **example** API to demonstrate features of OpenAPI specification 16 | tags: 17 | - name: Auth 18 | description: Authentication 19 | - name: Comments 20 | description: Comments 21 | - name: Messages 22 | description: Messages 23 | - name: User 24 | description: Operations about user 25 | servers: 26 | - url: '//api.host.example' 27 | components: 28 | responses: 29 | GenericError: 30 | description: Error response 31 | content: 32 | application/json: 33 | schema: 34 | type: object 35 | properties: 36 | errors: 37 | type: array 38 | items: 39 | type: object 40 | webhooks: 41 | one: 42 | $ref: webhooks/one.yaml 43 | paths: 44 | /health: 45 | get: 46 | operationId: health_check 47 | summary: Health check 48 | responses: 49 | '200': 50 | description: OK 51 | '400': 52 | $ref: ./components/responses/BadRequest.yaml 53 | '409': 54 | $ref: '#/components/responses/GenericError' 55 | '500': 56 | description: Server Error 57 | /html: 58 | get: 59 | operationId: get_html 60 | summary: HTML test 61 | responses: 62 | '200': 63 | description: Empty string 64 | content: 65 | text/html: 66 | schema: 67 | type: string 68 | maxLength: 1 69 | /numbers: 70 | get: 71 | operationId: numbers 72 | summary: Numbers 73 | responses: 74 | '200': 75 | description: Ok 76 | content: 77 | application/json: 78 | schema: 79 | $ref: 'components/schemas/Numbers.yaml#/All' 80 | /messages/last: 81 | $ref: 'paths/messages_last.yaml' 82 | /messages/{id}: 83 | $ref: 'paths/message.yaml' 84 | /pets: 85 | get: 86 | operationId: pets 87 | summary: Pets 88 | parameters: 89 | - in: query 90 | name: order 91 | schema: 92 | type: string 93 | enum: 94 | - asc 95 | - desc 96 | required: false 97 | responses: 98 | '200': 99 | description: Ok 100 | content: 101 | application/json: 102 | schema: 103 | type: array 104 | items: 105 | oneOf: 106 | - $ref: 'components/schemas/Polymorphism.yaml#/Pet' 107 | /dog: 108 | get: 109 | operationId: dog 110 | summary: Dog 111 | responses: 112 | '200': 113 | description: Ok 114 | content: 115 | application/json: 116 | schema: 117 | $ref: 'components/schemas/Polymorphism.yaml#/Dog' 118 | /comments/{id}: 119 | $ref: 'paths/comment.yaml' 120 | /user: 121 | $ref: 'paths/user.yaml' 122 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/other.openapi.yaml: -------------------------------------------------------------------------------- 1 | openapi.yaml -------------------------------------------------------------------------------- /spec/fixtures/openapi/paths/comment.yaml: -------------------------------------------------------------------------------- 1 | parameters: 2 | - name: id 3 | in: path 4 | description: Id of the comment. 5 | required: true 6 | schema: 7 | type: string 8 | get: 9 | tags: 10 | - Comment 11 | summary: Get Comment 12 | description: Get Comment 13 | operationId: get_comment 14 | responses: 15 | '200': 16 | description: OK 17 | patch: 18 | tags: 19 | - Comment 20 | summary: Update Comment 21 | description: Patch Comment 22 | operationId: patch_comment 23 | responses: 24 | '204': 25 | description: OK 26 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/paths/message.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | parameters: 3 | - $ref: ../components/parameters/messageId.yaml 4 | tags: 5 | - Message 6 | summary: Get Message 7 | description: Get Message 8 | operationId: get_message 9 | responses: 10 | '200': 11 | description: OK 12 | headers: 13 | x-request-id: 14 | schema: 15 | type: string 16 | required: true 17 | content: 18 | application/json: 19 | schema: 20 | type: object 21 | properties: 22 | data: 23 | $ref: ../components/schemas/Message.yaml 24 | required: 25 | - data 26 | additionalProperties: false 27 | '400': 28 | $ref: ../components/responses/BadRequest.yaml 29 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/paths/messages_last.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | tags: 3 | - Message 4 | summary: Last Message 5 | description: Get Last Message 6 | operationId: get_last_message 7 | responses: 8 | $ref: ./message.yaml#/get/responses 9 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/paths/user.yaml: -------------------------------------------------------------------------------- 1 | get: 2 | tags: 3 | - User 4 | summary: Get User 5 | description: Get User 6 | operationId: get_user 7 | responses: 8 | '200': 9 | description: OK 10 | headers: 11 | x-request-id: 12 | schema: 13 | type: string 14 | required: true 15 | content: 16 | application/json: 17 | schema: 18 | type: object 19 | properties: 20 | data: 21 | $ref: ../components/schemas/User.yaml 22 | required: 23 | - data 24 | additionalProperties: false 25 | '400': 26 | $ref: ../components/responses/BadRequest.yaml 27 | post: 28 | tags: 29 | - User 30 | summary: Create User 31 | description: Create User 32 | operationId: create_user 33 | requestBody: 34 | content: 35 | application/json: 36 | schema: 37 | type: object 38 | properties: 39 | data: 40 | type: object 41 | properties: 42 | type: 43 | type: string 44 | attributes: 45 | type: object 46 | properties: 47 | name: 48 | type: 49 | - string 50 | - null 51 | email: 52 | $ref: ../components/schemas/Email.yaml 53 | additionalProperties: false 54 | required: 55 | - name 56 | - email 57 | additionalProperties: false 58 | required: 59 | - type 60 | - attributes 61 | required: 62 | - data 63 | additionalProperties: false 64 | responses: 65 | '201': 66 | description: Created 67 | headers: 68 | x-request-id: 69 | schema: 70 | type: string 71 | required: true 72 | content: 73 | application/json: 74 | schema: 75 | type: object 76 | properties: 77 | data: 78 | $ref: ../components/schemas/User.yaml 79 | required: 80 | - data 81 | additionalProperties: false 82 | '400': 83 | $ref: ../components/responses/BadRequest.yaml 84 | -------------------------------------------------------------------------------- /spec/fixtures/openapi/webhooks/one.yaml: -------------------------------------------------------------------------------- 1 | post: 2 | operationId: WebhookOne 3 | summary: A webhook. 4 | requestBody: 5 | description: Webhook payload. 6 | content: 7 | application/json: 8 | schema: 9 | type: object 10 | responses: 11 | "204": 12 | description: No Content 13 | "400": 14 | description: Bad Request 15 | -------------------------------------------------------------------------------- /spec/integration/rspec_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe 'RSpec integration' do 2 | subject { response } 3 | 4 | include_context 'when using GET /user' 5 | 6 | it { is_expected.to match_openapi_doc(doc) } 7 | 8 | it { is_expected.to match_openapi_doc(doc).with_http_status(:ok) } 9 | it { is_expected.to match_openapi_doc(doc).with_http_status(200) } 10 | 11 | it { is_expected.to_not match_openapi_doc(doc).with_http_status(:created) } 12 | 13 | context 'when using component responses' do 14 | let(:response_status) { 400 } 15 | let(:response_json) do 16 | { 17 | errors: [{}] 18 | } 19 | end 20 | 21 | it { is_expected.to match_openapi_doc(doc).with_http_status(:bad_request) } 22 | end 23 | 24 | context 'when using polymorphism with discriminators' do 25 | let(:path) { '/pets' } 26 | let(:response_status) { 200 } 27 | let(:response_json) do 28 | [ 29 | { 30 | type: 'cat', 31 | age: 2 32 | }, 33 | { 34 | type: 'dog', 35 | age: 3, 36 | leash: false 37 | } 38 | ] 39 | end 40 | 41 | it { is_expected.to match_openapi_doc(doc).with_http_status(:ok) } 42 | 43 | context 'when encountering unknown types' do 44 | let(:response_json) do 45 | [ 46 | {type: 'other'} 47 | ] 48 | end 49 | 50 | it { is_expected.to_not match_openapi_doc(doc) } 51 | end 52 | 53 | context 'when not matching' do 54 | let(:response_json) do 55 | [ 56 | { 57 | type: 'dog', 58 | leash: 'missing' 59 | } 60 | ] 61 | end 62 | 63 | it { is_expected.to_not match_openapi_doc(doc) } 64 | end 65 | 66 | context 'when using compound objects' do 67 | let(:path) { '/dog' } 68 | let(:response_status) { 200 } 69 | let(:response_json) do 70 | { 71 | type: 'dog', 72 | age: 3, 73 | leash: true 74 | } 75 | end 76 | 77 | it { is_expected.to match_openapi_doc(doc) } 78 | end 79 | end 80 | 81 | context 'when using referenced responses' do 82 | let(:path) { '/messages/last' } 83 | let(:response_status) { 400 } 84 | let(:response_json) do 85 | { 86 | errors: [{}] 87 | } 88 | end 89 | 90 | it { is_expected.to match_openapi_doc(doc).with_http_status(:bad_request) } 91 | end 92 | 93 | context 'when using dynamic paths' do 94 | let(:path) { '/messages/ef278ab2' } 95 | let(:response_json) do 96 | { 97 | data: { 98 | id: '1ef', 99 | type: 'messages', 100 | attributes: { 101 | body: 'foo' 102 | } 103 | } 104 | } 105 | end 106 | 107 | it { is_expected.to match_openapi_doc(doc).with_http_status(:ok) } 108 | 109 | it { is_expected.to match_openapi_doc(doc, path: '/messages/{id}').with_http_status(:ok) } 110 | 111 | context 'when a string attribute is nullable' do 112 | before { response_json[:data][:attributes][:title] = nil } 113 | 114 | it { is_expected.to match_openapi_doc(doc) } 115 | end 116 | 117 | context 'when a object attribute is nullable' do 118 | before { response_json[:data][:attributes][:author] = nil } 119 | 120 | it { is_expected.to match_openapi_doc(doc) } 121 | end 122 | end 123 | 124 | context 'when a required header is missing' do 125 | before { response_headers.delete('X-Request-Id') } 126 | 127 | it { is_expected.to_not match_openapi_doc(doc) } 128 | end 129 | 130 | context 'when a required attribute is missing' do 131 | before { response_json[:data][:attributes].delete(:email) } 132 | 133 | it { is_expected.to_not match_openapi_doc(doc) } 134 | end 135 | 136 | context 'when an additional attribute is included' do 137 | before { response_json[:data][:attributes].merge!(other: 'foo') } 138 | 139 | it { is_expected.to_not match_openapi_doc(doc) } 140 | end 141 | 142 | context 'when a attribute does not match type' do 143 | before { response_json[:data][:id] = 1 } 144 | 145 | it { is_expected.to_not match_openapi_doc(doc) } 146 | end 147 | 148 | context 'when an attribute does match type oneOf' do 149 | before { response_json[:data][:attributes][:addresses] = {street: 'Somestreet'} } 150 | 151 | it { is_expected.to_not match_openapi_doc(doc) } 152 | end 153 | 154 | context 'when an attribute does not match type oneOf' do 155 | before { response_json[:data][:attributes][:addresses] = {foo: 'bar'} } 156 | 157 | it { is_expected.to_not match_openapi_doc(doc) } 158 | end 159 | 160 | context 'when the response is not documented' do 161 | let(:method) { 'POST' } 162 | 163 | it { is_expected.to_not match_openapi_doc(doc) } 164 | end 165 | 166 | context 'when request body is validated' do 167 | include_context 'when using POST /user' 168 | 169 | it { is_expected.to match_openapi_doc(doc, path: '/user', request_body: true).with_http_status(:created) } 170 | 171 | it { is_expected.to_not match_openapi_doc(doc, path: '/user', request_body: true).with_http_status(:ok) } 172 | end 173 | 174 | context 'when input parameters are validated' do 175 | let(:path) { '/pets?order=asc' } 176 | let(:response_json) do 177 | [ 178 | { 179 | type: 'cat', 180 | age: 2 181 | }, 182 | { 183 | type: 'dog', 184 | age: 3, 185 | leash: false 186 | } 187 | ] 188 | end 189 | let(:response_status) { 200 } 190 | 191 | it { is_expected.to match_openapi_doc(doc, parameters: true) } 192 | 193 | context 'when input parameters are not valid' do 194 | let(:path) { '/pets?order=wrong' } 195 | 196 | it { is_expected.to_not match_openapi_doc(doc, parameters: true) } 197 | end 198 | end 199 | 200 | context 'when validating html responses' do 201 | let(:method) { 'GET' } 202 | let(:path) { '/html' } 203 | let(:response_body) { ' ' } 204 | let(:response_headers) do 205 | { 206 | 'Content-Type' => 'text/html;charset=utf-8', 207 | 'X-Request-Id' => 'some-request-id' 208 | } 209 | end 210 | 211 | it { is_expected.to match_openapi_doc(doc).with_http_status(:ok) } 212 | 213 | context 'when the response is too long' do 214 | let(:response_body) { 'too long' } 215 | 216 | it { is_expected.to_not match_openapi_doc(doc).with_http_status(:ok) } 217 | end 218 | end 219 | end 220 | -------------------------------------------------------------------------------- /spec/openapi_contracts/coverage/report_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Coverage::Report do 2 | let(:doc) { OpenapiContracts::Doc.parse(openapi_dir) } 3 | let(:openapi_dir) { FIXTURES_PATH.join('openapi') } 4 | 5 | describe '.merge' do 6 | subject { described_class.merge(doc, report1, report2, report3) } 7 | 8 | let(:report1) do 9 | { 10 | '/user' => { 11 | 'get' => { 12 | '200' => { 13 | 'application/json' => 1 14 | } 15 | } 16 | } 17 | } 18 | end 19 | let(:report2) do 20 | { 21 | '/user' => { 22 | 'get' => { 23 | '200' => { 24 | 'application/json' => 2 25 | }, 26 | '400' => { 27 | 'text/html' => 2 28 | } 29 | } 30 | } 31 | } 32 | end 33 | let(:report3) do 34 | { 35 | '/health' => { 36 | 'get' => { 37 | '200' => { 38 | 'text/plain' => 1 39 | } 40 | } 41 | } 42 | } 43 | end 44 | 45 | it 'merges the data correctly' do 46 | expect(subject.data.dig('/user', 'get', '200', 'application/json')).to eq(3) 47 | expect(subject.data.dig('/user', 'get', '400', 'text/html')).to eq(2) 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/openapi_contracts/coverage/store_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Coverage::Store do 2 | subject { described_class.new } 3 | 4 | it 'stores coverage hits correctly' do 5 | subject.increment!('/health', 'get', '200', 'text/plain') 6 | subject.increment!('/user', 'get', '200', 'application/json') 7 | subject.increment!('/user', 'get', '200', 'application/json') 8 | subject.increment!('/user', 'get', '401', 'application/json') 9 | subject.increment!('/user', 'post', '400', 'application/json') 10 | subject.increment!('/user', 'post', '400', 'application/json') 11 | expect(subject.data).to eq( 12 | '/health' => {'get'=>{'200'=>{'text/plain'=>1}}}, 13 | '/user' => { 14 | 'get' => { 15 | '200' => {'application/json'=>2}, 16 | '401' => {'application/json'=>1} 17 | }, 18 | 'post' => {'400'=>{'application/json'=>2}} 19 | } 20 | ) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/openapi_contracts/coverage_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Coverage do 2 | subject(:coverage) { doc.coverage } 3 | 4 | let(:doc) { OpenapiContracts::Doc.parse(openapi_dir) } 5 | let(:openapi_dir) { FIXTURES_PATH.join('openapi') } 6 | 7 | describe '.merge_reports' do 8 | subject { described_class.merge_reports(doc, *[file1, file2, file3].map(&:path)) } 9 | 10 | let(:file1) do 11 | Tempfile.new.tap do |f| 12 | doc.coverage.clear! 13 | doc.coverage.increment!('/health', 'get', '200', 'text/plain') 14 | doc.coverage.increment!('/user', 'get', '200', 'application/json') 15 | doc.coverage.increment!('/user', 'get', '200', 'application/json') 16 | doc.coverage.report.generate(f.path) 17 | end 18 | end 19 | let(:file2) do 20 | Tempfile.new.tap do |f| 21 | doc.coverage.clear! 22 | doc.coverage.increment!('/user', 'get', '401', 'application/json') 23 | doc.coverage.increment!('/user', 'post', '201', 'application/json') 24 | doc.coverage.report.generate(f.path) 25 | end 26 | end 27 | let(:file3) do 28 | Tempfile.new.tap do |f| 29 | doc.coverage.clear! 30 | doc.coverage.store.increment!('/user', 'post', '400', 'application/json') 31 | doc.coverage.store.increment!('/user', 'post', '400', 'application/json') 32 | doc.coverage.report.generate(f.path) 33 | end 34 | end 35 | 36 | it 'can generate a report' do 37 | data = subject.as_json 38 | expect(data.dig('meta', 'operations', 'covered')).to eq(3) 39 | expect(data.dig('meta', 'operations', 'total')).to eq(11) 40 | expect(data.dig('meta', 'responses', 'covered')).to eq(4) 41 | expect(data.dig('meta', 'responses', 'total')).to eq(18) 42 | end 43 | end 44 | 45 | describe '.report' do 46 | subject { coverage.report } 47 | 48 | before do 49 | doc.coverage.store.increment!('/health', 'get', '200', 'text/plain') 50 | doc.coverage.store.increment!('/user', 'get', '200', 'application/json') 51 | doc.coverage.store.increment!('/user', 'get', '200', 'application/json') 52 | doc.coverage.store.increment!('/user', 'get', '401', 'application/json') 53 | doc.coverage.store.increment!('/user', 'post', '201', 'application/json') 54 | doc.coverage.store.increment!('/user', 'post', '400', 'application/json') 55 | doc.coverage.store.increment!('/user', 'post', '400', 'application/json') 56 | end 57 | 58 | it 'can generate a report', :aggregate_failures do 59 | data = subject.as_json 60 | expect(data.dig('meta', 'operations', 'covered')).to eq(3) 61 | expect(data.dig('meta', 'operations', 'total')).to eq(11) 62 | expect(data.dig('meta', 'responses', 'covered')).to eq(4) 63 | expect(data.dig('meta', 'responses', 'total')).to eq(18) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/parameter_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Parameter do 2 | subject(:parameter) { described_class.new(spec) } 3 | 4 | let(:spec) { OpenapiContracts::Doc::Schema.new(raw).navigate('components', 'parameters', 'Example') } 5 | let(:raw) do 6 | { 7 | 'openapi' => '3.0.0', 8 | 'components' => { 9 | 'parameters' => { 10 | 'Example' => { 11 | 'name' => 'example', 12 | 'schema' => schema 13 | } 14 | } 15 | } 16 | } 17 | end 18 | 19 | shared_examples 'a path parameter' do |expectations| 20 | expectations.each do |k, v| 21 | context "when the value is #{k.inspect}" do 22 | let(:value) { k } 23 | 24 | it { is_expected.to be v } 25 | end 26 | end 27 | end 28 | 29 | describe '#matches?(value)' do 30 | subject { parameter.matches?(value) } 31 | 32 | context 'when the param is a string with pattern' do 33 | let(:schema) do 34 | { 35 | 'type' => 'string', 36 | 'pattern' => '^[a-f,0-9]{8}$' 37 | } 38 | end 39 | 40 | include_examples 'a path parameter', { 41 | '1234abcd' => true, 42 | '123' => false 43 | } 44 | end 45 | 46 | context 'when the param is a string with length' do 47 | let(:schema) do 48 | { 49 | 'type' => 'string', 50 | 'minLength' => 4, 51 | 'maxLength' => 8 52 | } 53 | end 54 | 55 | include_examples 'a path parameter', { 56 | '1234' => true, 57 | '12' => false, 58 | '123456789' => false 59 | } 60 | end 61 | 62 | context 'when the param is an integer' do 63 | let(:schema) do 64 | { 65 | 'type' => 'integer' 66 | } 67 | end 68 | 69 | include_examples 'a path parameter', { 70 | '1234' => true, 71 | '1.234' => false, 72 | 'word' => false, 73 | '-1234' => true, 74 | '-1.234' => false 75 | } 76 | end 77 | 78 | context 'when the param is an integer with minimum -2' do 79 | let(:schema) do 80 | { 81 | 'type' => 'integer', 82 | 'minimum' => -2 83 | } 84 | end 85 | 86 | include_examples 'a path parameter', { 87 | '1234' => true, 88 | '-2' => true, 89 | '-1234' => false 90 | } 91 | end 92 | 93 | context 'when the param is an integer with exclusive minimum -2' do 94 | let(:schema) do 95 | { 96 | 'type' => 'integer', 97 | 'minimum' => -2, 98 | 'exclusiveMinimum' => true 99 | } 100 | end 101 | 102 | include_examples 'a path parameter', { 103 | '0' => true, 104 | '-2' => false 105 | } 106 | 107 | context 'when using openapi 3.1' do 108 | let(:schema) do 109 | { 110 | 'type' => 'integer', 111 | 'exclusiveMinimum' => -2 112 | } 113 | end 114 | 115 | before do 116 | raw['openapi'] = '3.1' 117 | end 118 | 119 | include_examples 'a path parameter', { 120 | '0' => true, 121 | '-2' => false 122 | } 123 | end 124 | end 125 | 126 | context 'when the param is an integer with maximum 2' do 127 | let(:schema) do 128 | { 129 | 'type' => 'integer', 130 | 'maximum' => 2 131 | } 132 | end 133 | 134 | include_examples 'a path parameter', { 135 | '1234' => false, 136 | '2' => true, 137 | '-2' => true, 138 | '-1234' => true 139 | } 140 | end 141 | 142 | context 'when the param is an integer with exclusive maximum 2' do 143 | let(:schema) do 144 | { 145 | 'type' => 'integer', 146 | 'maximum' => 2, 147 | 'exclusiveMaximum' => true 148 | } 149 | end 150 | 151 | include_examples 'a path parameter', { 152 | '2' => false, 153 | '0' => true 154 | } 155 | end 156 | 157 | context 'when the param is an integer with minimum 0 and maximum 1234' do 158 | let(:schema) do 159 | { 160 | 'type' => 'integer', 161 | 'minimum' => 0, 162 | 'maximum' => 1234 163 | } 164 | end 165 | 166 | include_examples 'a path parameter', { 167 | '1234' => true, 168 | '2' => true, 169 | '-2' => false, 170 | '-1234' => false 171 | } 172 | end 173 | 174 | context 'when the param is a number' do 175 | let(:schema) do 176 | { 177 | 'type' => 'number' 178 | } 179 | end 180 | 181 | include_examples 'a path parameter', { 182 | '1234' => true, 183 | '1.234' => true, 184 | 'word' => false, 185 | '-1234' => true, 186 | '-1.234' => true 187 | } 188 | end 189 | 190 | context 'when the param is a number with minimum -2' do 191 | let(:schema) do 192 | { 193 | 'type' => 'number', 194 | 'minimum' => -2 195 | } 196 | end 197 | 198 | include_examples 'a path parameter', { 199 | '1234' => true, 200 | '1.234' => true, 201 | 'word' => false, 202 | '-1234' => false, 203 | '-1.234' => true 204 | } 205 | end 206 | 207 | context 'when the param is a number with maximum 2' do 208 | let(:schema) do 209 | { 210 | 'type' => 'number', 211 | 'maximum' => 2 212 | } 213 | end 214 | 215 | include_examples 'a path parameter', { 216 | '1234' => false, 217 | '1.234' => true, 218 | 'word' => false, 219 | '-1234' => true, 220 | '-1.234' => true 221 | } 222 | end 223 | 224 | context 'when the param is a number with minimum 0 and maximum 1000' do 225 | let(:schema) do 226 | { 227 | 'type' => 'number', 228 | 'minimum' => 0, 229 | 'maximum' => 1000, 230 | 'exclusiveMaximum' => true 231 | } 232 | end 233 | 234 | include_examples 'a path parameter', { 235 | '1234' => false, 236 | '1.234' => true, 237 | 'word' => false, 238 | '-1234' => false, 239 | '-1.234' => false 240 | } 241 | end 242 | 243 | context 'when the param is an array' do 244 | let(:schema) do 245 | { 246 | 'type' => 'array' 247 | } 248 | end 249 | 250 | # Array is not yet supported and will not match 251 | include_examples 'a path parameter', { 252 | '1,2,3' => false 253 | } 254 | end 255 | end 256 | end 257 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/path_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Path do 2 | subject(:path) { doc.with_path('/messages/{id}') } 3 | 4 | let(:doc) { OpenapiContracts::Doc.new(schema) } 5 | let(:schema) do 6 | { 7 | 'paths' => { 8 | '/messages/{id}' => { 9 | 'parameters' => [id_param].compact 10 | }, 11 | '/messages/{id}/{second_id}' => { 12 | 'parameters' => [id_param, second_id_param].compact 13 | } 14 | } 15 | } 16 | end 17 | let(:id_param) do 18 | { 19 | 'name' => 'id', 20 | 'in' => 'path', 21 | 'required' => true, 22 | 'schema' => id_schema 23 | } 24 | end 25 | 26 | let(:second_id_param) do 27 | { 28 | 'name' => 'second_id', 29 | 'in' => 'path', 30 | 'required' => true, 31 | 'schema' => id_schema 32 | } 33 | end 34 | let(:id_schema) { {} } 35 | 36 | describe '#dynamic?' do 37 | subject { path.dynamic? } 38 | 39 | it { is_expected.to be true } 40 | end 41 | 42 | describe '#static?' do 43 | subject { path.static? } 44 | 45 | it { is_expected.to be false } 46 | end 47 | 48 | describe '#parameters' do 49 | subject { path.parameters } 50 | 51 | it 'returns a parsed list of path-wide parameters' do 52 | expect(subject).to be_a(Enumerable) 53 | expect(subject.size).to eq(1) 54 | subject.first.then do |param| 55 | expect(param).to be_a(OpenapiContracts::Doc::Parameter) 56 | expect(param.name).to eq('id') 57 | expect(param).to be_in_path 58 | end 59 | end 60 | end 61 | 62 | describe '#path_regexp' do 63 | context 'when there are two parameters' do 64 | subject { doc.with_path('/messages/{id}/{second_id}').path_regexp.match('/messages/123/abc').captures } 65 | 66 | it 'matches both parameters' do 67 | expect(subject).to eq %w(123 abc) 68 | end 69 | end 70 | 71 | context 'when there is a trailing path' do 72 | subject { doc.with_path('/messages/{id}').path_regexp.match?('/messages/123/trailing') } 73 | 74 | it 'does not match' do 75 | expect(subject).to be false 76 | end 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/pointer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Pointer do 2 | subject(:pointer) { described_class.new(segments) } 3 | 4 | let(:segments) { %w(foo bar) } 5 | 6 | describe '#inspect' do 7 | subject { pointer.inspect } 8 | 9 | it { is_expected.to eq '' } 10 | end 11 | 12 | describe '#parent' do 13 | subject { pointer.parent } 14 | 15 | it { is_expected.to eq described_class['foo'] } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/request_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Request do 2 | subject(:request) { doc.operation_for(path, method).request_body } 3 | 4 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 5 | let(:path) { '/user' } 6 | let(:method) { 'post' } 7 | 8 | context 'when there is no request_body defined on the operation' do 9 | let(:method) { 'get' } 10 | 11 | it { is_expected.to be_nil } 12 | end 13 | 14 | describe '#supports_media_type?' do 15 | subject { request.supports_media_type?(media_type) } 16 | 17 | context 'when media_type is supported' do 18 | let(:media_type) { 'application/json' } 19 | 20 | it { is_expected.to be true } 21 | end 22 | 23 | context 'when media_type is not supported' do 24 | let(:media_type) { 'application/text' } 25 | 26 | it { is_expected.to be false } 27 | end 28 | end 29 | 30 | describe '#schema_for' do 31 | subject { request.schema_for(media_type) } 32 | 33 | context 'when media_type is supported' do 34 | let(:media_type) { 'application/json' } 35 | 36 | it { is_expected.to be_a(OpenapiContracts::Doc::Schema) } 37 | end 38 | 39 | context 'when media_type is not supported' do 40 | let(:media_type) { 'application/text' } 41 | 42 | it { is_expected.to be_nil } 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/response_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Response do 2 | subject(:response) { doc.operation_for(path, method).response_for_status(status) } 3 | 4 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 5 | 6 | context 'with content-less responses' do 7 | subject(:response) { doc.operation_for('/health', 'get').response_for_status('200') } 8 | 9 | describe '#no_content?' do 10 | subject { response.no_content? } 11 | 12 | it { is_expected.to be true } 13 | end 14 | 15 | describe '#supports_media_type?' do 16 | subject { response.supports_media_type?('application/json') } 17 | 18 | it { is_expected.to be false } 19 | end 20 | end 21 | 22 | context 'with standard responses' do 23 | subject(:response) { doc.operation_for('/user', 'get').response_for_status('200') } 24 | 25 | describe '#no_content?' do 26 | subject { response.no_content? } 27 | 28 | it { is_expected.to be false } 29 | end 30 | 31 | describe '#supports_media_type?' do 32 | subject { response.supports_media_type?(media_type) } 33 | 34 | context 'when media_type is supported' do 35 | let(:media_type) { 'application/json' } 36 | 37 | it { is_expected.to be true } 38 | end 39 | 40 | context 'when media_type is not supported' do 41 | let(:media_type) { 'application/text' } 42 | 43 | it { is_expected.to be false } 44 | end 45 | end 46 | 47 | describe '#schema_for' do 48 | subject { response.schema_for(media_type) } 49 | 50 | context 'when media_type is supported' do 51 | let(:media_type) { 'application/json' } 52 | 53 | it { is_expected.to be_a(OpenapiContracts::Doc::Schema) } 54 | end 55 | 56 | context 'when media_type is not supported' do 57 | let(:media_type) { 'application/text' } 58 | 59 | it { is_expected.to be_nil } 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc/schema_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc::Schema do 2 | subject(:schema) { doc.schema } 3 | 4 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 5 | 6 | describe '#each' do 7 | it 'can enumerate the contents' do 8 | expect(schema.navigate('paths', '/messages/last', 'get', 'responses').each.size).to eq(2) 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/openapi_contracts/doc_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Doc do 2 | subject(:doc) { described_class.parse(openapi_dir) } 3 | 4 | let(:openapi_dir) { FIXTURES_PATH.join('openapi') } 5 | 6 | describe '.parse' do 7 | subject(:defined_doc) { described_class.parse(openapi_dir, 'other.openapi.yaml') } 8 | 9 | it 'parses default document when not defined' do 10 | expect(doc).to be_a(described_class) 11 | end 12 | 13 | it 'parses correct document when defined' do 14 | expect(defined_doc).to be_a(described_class) 15 | end 16 | end 17 | 18 | describe '#operation_for' do 19 | subject { doc.operation_for(path, method) } 20 | 21 | let(:path) { '/user' } 22 | let(:method) { 'get' } 23 | 24 | it { is_expected.to be_a(OpenapiContracts::Doc::Operation) } 25 | end 26 | 27 | describe '#responses' do 28 | subject { doc.responses } 29 | 30 | it { is_expected.to all be_a(OpenapiContracts::Doc::Response) } 31 | 32 | it { is_expected.to have_attributes(count: 18) } 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/openapi_contracts/match_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Match do 2 | subject do 3 | described_class.new(doc, response, options) 4 | end 5 | 6 | let(:options) { {} } 7 | 8 | before { allow(OpenapiContracts).to receive(:collect_coverage).and_return(true) } 9 | 10 | include_context 'when using GET /user' 11 | 12 | it { is_expected.to be_valid } 13 | 14 | it 'registers the coverage' do 15 | subject.valid? 16 | expect(doc.coverage.data).to eq( 17 | '/user' => { 18 | 'get' => { 19 | '200' => { 20 | 'application/json' => 1 21 | } 22 | } 23 | } 24 | ) 25 | end 26 | 27 | context 'when using a no-content endpoint' do 28 | include_context 'when using PATCH /comments/{id}' 29 | 30 | it { is_expected.to be_valid } 31 | 32 | it 'registers the coverage' do 33 | subject.valid? 34 | expect(doc.coverage.data).to eq( 35 | '/comments/{id}' => { 36 | 'patch' => { 37 | '204' => { 38 | 'no_content' => 1 39 | } 40 | } 41 | } 42 | ) 43 | end 44 | end 45 | 46 | context 'when using nocov option' do 47 | let(:options) { {nocov: true} } 48 | 49 | it 'does not register coverage' do 50 | subject.valid? 51 | expect(doc.coverage.data).to eq({}) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/openapi_contracts/operation_router_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::OperationRouter do 2 | subject(:router) { described_class.new(doc) } 3 | 4 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 5 | 6 | describe '#route(path, method)' do 7 | subject { router.route(path, method) } 8 | 9 | context 'when routing "GET /messages/acbd1234"' do 10 | let(:method) { 'get' } 11 | let(:path) { '/messages/abcd1234' } 12 | 13 | it 'routes correctly' do 14 | expect(subject).to be_present 15 | expect(subject).to be_a(OpenapiContracts::Doc::Operation) 16 | end 17 | end 18 | 19 | context 'when routing "GET /messages/acbd"' do 20 | let(:method) { 'get' } 21 | let(:path) { '/messages/abcd' } 22 | 23 | it { is_expected.to be_nil } 24 | end 25 | 26 | context 'when routing "GET /user"' do 27 | let(:method) { 'get' } 28 | let(:path) { '/user' } 29 | 30 | it 'routes correctly' do 31 | expect(subject).to be_present 32 | expect(subject).to be_a(OpenapiContracts::Doc::Operation) 33 | end 34 | end 35 | 36 | context 'when routing "DELETE /user"' do 37 | let(:method) { 'delete' } 38 | let(:path) { '/user' } 39 | 40 | it { is_expected.to be_nil } 41 | end 42 | 43 | context 'when routing "GET /unknown"' do 44 | let(:method) { 'get' } 45 | let(:path) { '/unknown' } 46 | 47 | it { is_expected.to be_nil } 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/openapi_contracts/payload_parser_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::PayloadParser do 2 | describe '.parse(media_type, raw_body)' do 3 | subject { described_class.parse(media_type, raw_body) } 4 | 5 | context 'when the media-type is application/json' do 6 | let(:media_type) { 'application/json' } 7 | let(:raw_body) { '{"hello":"world"}' } 8 | 9 | it 'parses correctly' do 10 | expect(subject).to be_a(Hash) 11 | expect(subject).to eq('hello' => 'world') 12 | end 13 | end 14 | 15 | context 'when the media-type is application/vnd.api+json' do 16 | let(:media_type) { 'application/vnd.api+json' } 17 | let(:raw_body) { '{"hello":"world"}' } 18 | 19 | it 'parses correctly' do 20 | expect(subject).to be_a(Hash) 21 | expect(subject).to eq('hello' => 'world') 22 | end 23 | end 24 | 25 | context 'when the media-type is application/x-www-form-urlencoded' do 26 | let(:media_type) { 'application/x-www-form-urlencoded' } 27 | let(:raw_body) { 'hello=world' } 28 | 29 | it 'parses correctly' do 30 | expect(subject).to be_a(Hash) 31 | expect(subject).to eq('hello' => 'world') 32 | end 33 | end 34 | 35 | context 'when the media-type is application/x-www-form-urlencoded with Array' do 36 | let(:media_type) { 'application/x-www-form-urlencoded' } 37 | let(:raw_body) { 'hello[]=world&hello[]=foo' } 38 | 39 | it 'parses correctly' do 40 | expect(subject).to be_a(Hash) 41 | expect(subject).to eq('hello' => %w(world foo)) 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/openapi_contracts/validators/documented_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Validators::Documented do 2 | subject { described_class.new(stack, env) } 3 | 4 | let(:env) do 5 | OpenapiContracts::Env.new(operation:, response:, request: response.request) 6 | end 7 | let(:operation) { doc.operation_for(path, method) } 8 | let(:stack) { ->(errors) { errors } } 9 | 10 | include_context 'when using GET /user' 11 | 12 | context 'when the operation is not documented' do 13 | let(:path) { '/unknown' } 14 | 15 | it 'returns an error' do 16 | expect(subject.call).to eq ['Undocumented operation for "GET /unknown"'] 17 | end 18 | end 19 | 20 | context 'when the response status is not documented' do 21 | let(:response_status) { '204' } 22 | 23 | it 'returns an error' do 24 | expect(subject.call).to eq ['Undocumented response for "GET /user" with http status No Content (204)'] 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/openapi_contracts/validators/headers_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Validators::Headers do 2 | subject { described_class.new(stack, env) } 3 | 4 | let(:env) { OpenapiContracts::Env.new(operation:, response:) } 5 | let(:operation) { doc.operation_for(path, method) } 6 | let(:stack) { ->(errors) { errors } } 7 | 8 | include_context 'when using GET /user' 9 | 10 | context 'when the headers match the schema' do 11 | it 'has no errors' do 12 | expect(subject.call).to be_empty 13 | end 14 | end 15 | 16 | context 'when header has multiple values' do 17 | before do 18 | response_headers['X-Request-Id'] = %w(val1 val2) 19 | end 20 | 21 | it 'has no errors' do 22 | expect(subject.call).to be_empty 23 | end 24 | end 25 | 26 | context 'when missing a header' do 27 | before do 28 | response_headers.delete('X-Request-Id') 29 | end 30 | 31 | it 'returns the error' do 32 | expect(subject.call).to eq [ 33 | 'Missing header x-request-id' 34 | ] 35 | end 36 | end 37 | 38 | context 'when the schema does not match' do 39 | before do 40 | response_headers['X-Request-Id'] = 1 41 | end 42 | 43 | it 'returns the error' do 44 | expect(subject.call).to eq [ 45 | 'Header x-request-id validation error: value at root is not a string (value: 1)' 46 | ] 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/openapi_contracts/validators/parameters_spec.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/object/json' 2 | 3 | RSpec.describe OpenapiContracts::Validators::Parameters do 4 | subject { described_class.new(stack, env) } 5 | 6 | include_context 'when using GET /pets' 7 | 8 | let(:env) { OpenapiContracts::Env.new(operation:, request:, response:) } 9 | let(:operation) { doc.operation_for('/pets', method) } 10 | let(:stack) { ->(errors) { errors } } 11 | let(:doc) do 12 | OpenapiContracts::Doc.new( 13 | { 14 | paths: { 15 | '/pets': { 16 | get: { 17 | parameters: [ 18 | { 19 | in: 'query', 20 | name: 'order', 21 | required:, 22 | schema: { 23 | type: 'string', 24 | enum: %w(asc desc) 25 | } 26 | }, 27 | { 28 | in: 'query', 29 | name: 'page', 30 | required: false, 31 | schema: { 32 | type: 'integer' 33 | } 34 | }, 35 | { 36 | in: 'query', 37 | name: 'settings', 38 | style: 'deepObject', 39 | required: false, 40 | schema: { 41 | type: 'object', 42 | properties: { 43 | page: { 44 | type: 'integer' 45 | } 46 | }, 47 | required: ['page'] 48 | } 49 | } 50 | ], 51 | responses: { 52 | '200': { 53 | description: 'Ok', 54 | content: { 55 | 'application/json': {} 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | }.as_json 63 | ) 64 | end 65 | 66 | context 'when optional parameters are missing' do 67 | let(:path) { '/pets' } 68 | let(:required) { false } 69 | 70 | it 'has no errors' do 71 | expect(subject.call).to be_empty 72 | end 73 | end 74 | 75 | context 'when required parameters are missing' do 76 | let(:path) { '/pets' } 77 | let(:required) { true } 78 | 79 | it 'has errors' do 80 | expect(subject.call).to contain_exactly 'Missing query parameter "order"' 81 | end 82 | end 83 | 84 | context 'when required parameters are present' do 85 | let(:path) { '/pets?order=asc' } 86 | let(:required) { true } 87 | 88 | it 'has no errors' do 89 | expect(subject.call).to be_empty 90 | end 91 | end 92 | 93 | context 'when parameters are wrong' do 94 | let(:path) { '/pets?order=bad' } 95 | let(:required) { false } 96 | 97 | it 'has errors' do 98 | expect(subject.call).to contain_exactly '"bad" is not a valid value for the query parameter "order"' 99 | end 100 | end 101 | 102 | context 'when passing invalid integer parameter' do 103 | let(:path) { '/pets?page=word' } 104 | let(:required) { false } 105 | 106 | it 'has errors' do 107 | expect(subject.call).to contain_exactly '"word" is not a valid value for the query parameter "page"' 108 | end 109 | end 110 | 111 | context 'when passing valid objects' do 112 | let(:path) { '/pets?settings[page]=1' } 113 | let(:required) { false } 114 | 115 | it 'has no errors' do 116 | expect(subject.call).to be_empty 117 | end 118 | end 119 | 120 | context 'when passing invalid objects' do 121 | let(:path) { '/pets?settings[page]=one' } 122 | let(:required) { false } 123 | 124 | it 'has errors' do 125 | details = {'page' => 'one'}.inspect 126 | expect(subject.call).to contain_exactly "#{details} is not a valid value for the query parameter \"settings\"" 127 | end 128 | end 129 | 130 | context 'when passing non-objects as object' do 131 | let(:path) { '/pets?settings=false' } 132 | let(:required) { false } 133 | 134 | it 'has errors' do 135 | expect(subject.call).to contain_exactly '"false" is not a valid value for the query parameter "settings"' 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/openapi_contracts/validators/request_body_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Validators::RequestBody do 2 | subject { described_class.new(stack, env) } 3 | 4 | let(:env) do 5 | OpenapiContracts::Env.new(operation:, response:, request: response.request) 6 | end 7 | let(:operation) { doc.operation_for(path, method) } 8 | let(:stack) { ->(errors) { errors } } 9 | 10 | include_context 'when using POST /user' 11 | 12 | context 'when the request body matches the schema' do 13 | it 'has no errors' do 14 | expect(subject.call).to be_empty 15 | end 16 | end 17 | 18 | context 'when having multiple errors' do 19 | let(:request_json) do 20 | { 21 | data: { 22 | id: 'a2kfn2', 23 | type: nil, 24 | attributes: { 25 | name: 'Joe' 26 | } 27 | } 28 | } 29 | end 30 | 31 | let(:error_disallowed_additional_property) do 32 | # The exact wording of the error messages changed with version 2.2.0 of json_schemer gem 33 | # https://github.com/davishmcclurg/json_schemer/commit/e8750cf682f94718c2188e6d3867d45e5d66ca73 34 | if Gem.loaded_specs['json_schemer'].version < Gem::Version.create('2.2') 35 | 'object property at `/data/id` is not defined and schema does not allow additional properties' 36 | else 37 | 'object property at `/data/id` is a disallowed additional property' 38 | end 39 | end 40 | 41 | it 'returns all errors' do 42 | expect(subject.call).to contain_exactly( 43 | error_disallowed_additional_property, 44 | 'null at `/data/type` is not a string', 45 | 'object at `/data/attributes` is missing required properties: email' 46 | ) 47 | end 48 | end 49 | 50 | context 'when the request body has a different content type' do 51 | let(:content_type) { 'application/xml' } 52 | 53 | it 'returns an error' do 54 | expect(subject.call).to eq ['Undocumented request with media-type "application/xml"'] 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/openapi_contracts/validators/response_body_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts::Validators::ResponseBody do 2 | subject { described_class.new(stack, env) } 3 | 4 | let(:env) { OpenapiContracts::Env.new(operation:, response:) } 5 | let(:operation) { doc.operation_for(path, method) } 6 | let(:stack) { ->(errors) { errors } } 7 | 8 | include_context 'when using GET /user' do 9 | let(:response_json) { {data: user} } 10 | end 11 | 12 | context 'when the body matches the schema' do 13 | let(:user) do 14 | { 15 | id: '123', 16 | type: 'user', 17 | attributes: { 18 | name: 'Joe', 19 | email: 'joe@host.example', 20 | rank: 1.0 21 | } 22 | } 23 | end 24 | 25 | it 'has no errors' do 26 | expect(subject.call).to be_empty 27 | end 28 | end 29 | 30 | context 'when having nilled fields' do 31 | let(:user) do 32 | { 33 | id: '123', 34 | type: 'user', 35 | attributes: { 36 | name: nil, 37 | email: 'joe@host.example', 38 | addresses: nil, 39 | rank: nil 40 | } 41 | } 42 | end 43 | 44 | it 'has no errors' do 45 | expect(subject.call).to be_empty 46 | end 47 | end 48 | 49 | context 'when having multiple errors' do 50 | let(:user) do 51 | { 52 | id: 1, 53 | type: nil, 54 | attributes: { 55 | name: 'Joe', 56 | rank: 1 57 | } 58 | } 59 | end 60 | 61 | it 'returns all errors' do 62 | expect(subject.call).to contain_exactly( 63 | '1 at `/data/attributes/rank` does not match format: float', 64 | '1 at `/data/id` is not a string', 65 | '1 at `/data/attributes/rank` is not null', 66 | 'null at `/data/type` is not a string', 67 | 'object at `/data/attributes` is missing required properties: email' 68 | ) 69 | end 70 | end 71 | 72 | context 'when the response body has a different content type' do 73 | before do 74 | response_headers['Content-Type'] = 'application/xml' 75 | end 76 | 77 | let(:response_body) { '' } 78 | 79 | it 'returns an error' do 80 | expect(subject.call).to eq ['Undocumented response with content-type "application/xml"'] 81 | end 82 | end 83 | 84 | context 'when the response should have no content' do 85 | let(:path) { '/health' } 86 | let(:response_body) { '' } 87 | 88 | it 'returns no error' do 89 | expect(subject.call).to eq [] 90 | end 91 | end 92 | 93 | context 'when the response should have no content but has' do 94 | let(:path) { '/health' } 95 | let(:response_body) { 'OK' } 96 | 97 | it 'returns an error' do 98 | expect(subject.call).to eq ['Expected empty response body'] 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /spec/openapi_contracts_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe OpenapiContracts do 2 | include_context 'when using GET /user' 3 | 4 | describe '.hash_bury' do 5 | subject { described_class.hash_bury({}, %i(foo bar), 'test') } 6 | 7 | it { is_expected.to eq({foo: {bar: 'test'}}) } 8 | end 9 | 10 | describe '.hash_bury!' do 11 | subject { {}.tap { |h| described_class.hash_bury!(h, %i(foo bar), 'test') } } 12 | 13 | it { is_expected.to eq({foo: {bar: 'test'}}) } 14 | end 15 | 16 | describe '.match' do 17 | subject { described_class.match(doc, response) } 18 | 19 | it { is_expected.to be_a(described_class::Match) } 20 | 21 | it { is_expected.to be_valid } 22 | 23 | context 'when the status does not match' do 24 | subject { described_class.match(doc, response, status: 201) } 25 | 26 | it 'is invalid and exposes all errors' do 27 | expect(subject).to_not be_valid 28 | expect(subject.errors).to eq ['Response has http status OK (200)'] 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | if ENV['COVERAGE'] 2 | require 'simplecov' 3 | SimpleCov.start do 4 | add_filter '/spec' 5 | end 6 | SimpleCov.minimum_coverage 99 7 | end 8 | 9 | require 'rubygems' 10 | require 'bundler' 11 | Bundler.require :default, 'test' 12 | 13 | require 'json_spec' 14 | 15 | Dir[File.expand_path('support/**/*.rb', __dir__)].each { |f| require f } 16 | 17 | FIXTURES_PATH = Pathname.new(__dir__).join('fixtures') 18 | -------------------------------------------------------------------------------- /spec/support/setup_context.rb: -------------------------------------------------------------------------------- 1 | RSpec.shared_context 'when using GET /user' do 2 | let(:response) do 3 | TestResponse[response_status, response_headers, response_body].tap do |resp| 4 | resp.request = TestRequest.build(path, method:) 5 | end 6 | end 7 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 8 | let(:method) { 'GET' } 9 | let(:path) { '/user' } 10 | let(:response_body) { JSON.dump(response_json) } 11 | let(:response_headers) do 12 | { 13 | 'Content-Type' => 'application/json;charset=utf-8', 14 | 'X-Request-Id' => 'some-request-id' 15 | } 16 | end 17 | let(:response_json) do 18 | { 19 | data: { 20 | id: 'some-id', 21 | type: 'user', 22 | attributes: { 23 | name: nil, 24 | email: 'name@me.example' 25 | } 26 | } 27 | } 28 | end 29 | let(:response_status) { 200 } 30 | end 31 | 32 | RSpec.shared_context 'when using POST /user' do 33 | let(:response) do 34 | TestResponse[response_status, response_headers, response_body].tap do |resp| 35 | resp.request = TestRequest.build(path, method:, input: request_body) 36 | resp.request.set_header('CONTENT_TYPE', content_type) 37 | end 38 | end 39 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 40 | let(:method) { 'POST' } 41 | let(:path) { '/user' } 42 | let(:content_type) { 'application/json' } 43 | let(:response_body) { JSON.dump(response_json) } 44 | let(:request_body) { JSON.dump(request_json) } 45 | let(:response_headers) do 46 | { 47 | 'Content-Type' => 'application/json;charset=utf-8', 48 | 'X-Request-Id' => 'some-request-id' 49 | } 50 | end 51 | let(:request_json) do 52 | { 53 | data: { 54 | type: 'user', 55 | attributes: { 56 | name: 'john', 57 | email: 'name@me.example' 58 | } 59 | } 60 | } 61 | end 62 | let(:response_json) do 63 | { 64 | data: { 65 | id: 'some-id', 66 | type: 'user', 67 | attributes: { 68 | name: 'john', 69 | email: 'name@me.example' 70 | } 71 | } 72 | } 73 | end 74 | let(:response_status) { 201 } 75 | end 76 | 77 | RSpec.shared_context 'when using GET /pets' do 78 | let(:request) { TestRequest.build(path, method:) } 79 | let(:response) do 80 | TestResponse[response_status, response_headers, response_body].tap do |resp| 81 | resp.request = request 82 | end 83 | end 84 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 85 | let(:method) { 'GET' } 86 | let(:path) { '/pets' } 87 | let(:response_body) { JSON.dump(response_json) } 88 | let(:response_headers) do 89 | { 90 | 'Content-Type' => 'application/json;charset=utf-8', 91 | 'X-Request-Id' => 'some-request-id' 92 | } 93 | end 94 | let(:response_json) do 95 | [ 96 | { 97 | type: 'cat' 98 | }, 99 | { 100 | type: 'dog' 101 | } 102 | ] 103 | end 104 | let(:response_status) { 200 } 105 | end 106 | 107 | RSpec.shared_context 'when using PATCH /comments/{id}' do 108 | let(:response) do 109 | TestResponse[response_status, response_headers, response_body].tap do |resp| 110 | resp.request = TestRequest.build(path, method:) 111 | end 112 | end 113 | let(:doc) { OpenapiContracts::Doc.parse(FIXTURES_PATH.join('openapi')) } 114 | let(:method) { 'PATCH' } 115 | let(:path) { '/comments/ef159a' } 116 | let(:response_body) { '' } 117 | let(:response_headers) do 118 | { 119 | 'X-Request-Id' => 'some-request-id' 120 | } 121 | end 122 | let(:response_status) { 204 } 123 | end 124 | -------------------------------------------------------------------------------- /spec/support/test_response.rb: -------------------------------------------------------------------------------- 1 | class TestResponse < Rack::Response 2 | # Rack::Response can not access request 3 | # Make our response behave more like ActionDispatch::Response 4 | attr_accessor :request 5 | end 6 | 7 | class TestRequest < Rack::Request 8 | def self.build(path, opts = {}) 9 | new Rack::MockRequest.env_for(path, opts) 10 | end 11 | end 12 | --------------------------------------------------------------------------------