├── examples
├── rails_app
│ ├── .rspec
│ ├── .gitignore
│ ├── config.ru
│ ├── Gemfile
│ ├── spec
│ │ ├── requests
│ │ │ ├── openapi_spec.rb
│ │ │ ├── bar
│ │ │ │ └── bar_spec.rb
│ │ │ └── baz
│ │ │ │ └── baz_spec.rb
│ │ └── rails_helper.rb
│ ├── app.rb
│ ├── docs
│ │ ├── bar_openapi.yml
│ │ └── baz_openapi.yml
│ └── README.md
├── Gemfile-rspec
├── Gemfile-minitest
├── test_app.rb
├── minitest.rb
├── rspec.rb
└── openapi.yml
├── .rspec
├── lib
├── skooma
│ ├── version.rb
│ ├── matchers
│ │ ├── conform_schema.rb
│ │ ├── conform_response_schema.rb
│ │ ├── be_valid_document.rb
│ │ ├── conform_request_schema.rb
│ │ └── wrapper.rb
│ ├── keywords
│ │ ├── oas_3_1
│ │ │ ├── dialect
│ │ │ │ ├── xml.rb
│ │ │ │ ├── example.rb
│ │ │ │ ├── external_docs.rb
│ │ │ │ ├── any_of.rb
│ │ │ │ ├── one_of.rb
│ │ │ │ └── discriminator.rb
│ │ │ └── schema.rb
│ │ └── oas_3_1.rb
│ ├── objects
│ │ ├── base
│ │ │ └── keywords
│ │ │ │ ├── tags.rb
│ │ │ │ ├── security.rb
│ │ │ │ ├── servers.rb
│ │ │ │ ├── summary.rb
│ │ │ │ ├── deprecated.rb
│ │ │ │ └── description.rb
│ │ ├── header
│ │ │ └── keywords
│ │ │ │ ├── style.rb
│ │ │ │ ├── example.rb
│ │ │ │ ├── examples.rb
│ │ │ │ ├── explode.rb
│ │ │ │ ├── schema.rb
│ │ │ │ ├── required.rb
│ │ │ │ └── content.rb
│ │ ├── openapi
│ │ │ └── keywords
│ │ │ │ ├── info.rb
│ │ │ │ ├── security.rb
│ │ │ │ ├── webhooks.rb
│ │ │ │ ├── json_schema_dialect.rb
│ │ │ │ ├── components.rb
│ │ │ │ ├── openapi.rb
│ │ │ │ └── paths.rb
│ │ ├── parameter
│ │ │ └── keywords
│ │ │ │ ├── in.rb
│ │ │ │ ├── name.rb
│ │ │ │ ├── allow_reserved.rb
│ │ │ │ ├── allow_empty_value.rb
│ │ │ │ ├── schema.rb
│ │ │ │ ├── required.rb
│ │ │ │ ├── content.rb
│ │ │ │ └── value_parser.rb
│ │ ├── response
│ │ │ └── keywords
│ │ │ │ ├── links.rb
│ │ │ │ ├── headers.rb
│ │ │ │ └── content.rb
│ │ ├── media_type.rb
│ │ ├── operation
│ │ │ └── keywords
│ │ │ │ ├── callbacks.rb
│ │ │ │ ├── operation_id.rb
│ │ │ │ ├── request_body.rb
│ │ │ │ ├── responses.rb
│ │ │ │ └── parameters.rb
│ │ ├── callback.rb
│ │ ├── path_item
│ │ │ └── keywords
│ │ │ │ ├── get.rb
│ │ │ │ ├── head.rb
│ │ │ │ ├── post.rb
│ │ │ │ ├── put.rb
│ │ │ │ ├── patch.rb
│ │ │ │ ├── trace.rb
│ │ │ │ ├── delete.rb
│ │ │ │ ├── options.rb
│ │ │ │ ├── parameters.rb
│ │ │ │ └── base_operation.rb
│ │ ├── request_body.rb
│ │ ├── response.rb
│ │ ├── request_body
│ │ │ └── keywords
│ │ │ │ └── required.rb
│ │ ├── header.rb
│ │ ├── base.rb
│ │ ├── path_item.rb
│ │ ├── parameter.rb
│ │ ├── operation.rb
│ │ ├── ref_base.rb
│ │ ├── components.rb
│ │ └── openapi.rb
│ ├── validators
│ │ ├── float.rb
│ │ ├── double.rb
│ │ ├── int_32.rb
│ │ └── int_64.rb
│ ├── inflector.rb
│ ├── body_parsers.rb
│ ├── output_format.rb
│ ├── rspec.rb
│ ├── minitest.rb
│ ├── env_mapper.rb
│ ├── dialects
│ │ └── oas_3_1.rb
│ ├── coverage.rb
│ └── instance.rb
└── skooma.rb
├── .standard.yml
├── bin
├── setup
├── console
└── example
├── .gitignore
├── Rakefile
├── Gemfile
├── spec
├── spec_helper.rb
├── skooma_spec.rb
└── openapi_test_suite
│ ├── openapi_formats.json
│ ├── response_headers.json
│ ├── query_params.json
│ ├── meta_type_matches.json
│ ├── discriminator.json
│ ├── template_routes.json
│ ├── header_params.json
│ └── simple_routing.json
├── data
└── oas-3.1
│ ├── schema-base
│ └── 2022-10-07.json
│ ├── dialect
│ └── base.json
│ └── meta
│ └── base.json
├── LICENSE.txt
├── skooma.gemspec
├── .github
└── workflows
│ └── main.yml
├── CHANGELOG.md
├── README.md
└── assets
└── logo.svg
/examples/rails_app/.rspec:
--------------------------------------------------------------------------------
1 | --require rails_helper
2 |
--------------------------------------------------------------------------------
/examples/rails_app/.gitignore:
--------------------------------------------------------------------------------
1 | /tmp/
2 |
3 | Gemfile.lock
4 |
--------------------------------------------------------------------------------
/.rspec:
--------------------------------------------------------------------------------
1 | --format documentation
2 | --color
3 | --require spec_helper
4 |
--------------------------------------------------------------------------------
/lib/skooma/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | VERSION = "0.3.3"
5 | end
6 |
--------------------------------------------------------------------------------
/.standard.yml:
--------------------------------------------------------------------------------
1 | # For available configuration options, see:
2 | # https://github.com/testdouble/standard
3 | ruby_version: 2.6
4 |
--------------------------------------------------------------------------------
/examples/rails_app/config.ru:
--------------------------------------------------------------------------------
1 | # This file is used by Rack-based servers to start the application.
2 |
3 | require_relative "app"
4 |
5 | run Rails.application
6 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 |
10 | # rspec failure tracking
11 | .rspec_status
12 |
13 | Gemfile.lock
14 | /examples/*.lock
15 |
--------------------------------------------------------------------------------
/examples/Gemfile-rspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rspec"
6 | gem "rack-test"
7 | gem "skooma", (ENV["CI"] == "1") ? {path: File.join(__dir__, "..")} : {}
8 | gem "sinatra"
9 |
--------------------------------------------------------------------------------
/examples/Gemfile-minitest:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "minitest"
6 | gem "rack-test"
7 | gem "skooma", (ENV["CI"] == "1") ? {path: File.join(__dir__, "..")} : {}
8 | gem "sinatra"
9 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 | require "rspec/core/rake_task"
5 |
6 | RSpec::Core::RakeTask.new(:spec)
7 |
8 | require "standard/rake"
9 |
10 | task default: %i[spec standard]
11 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | # Specify your gem's dependencies in skooma.gemspec
6 | gemspec
7 |
8 | gem "rake", "~> 13.0"
9 |
10 | gem "rspec", "~> 3.0"
11 |
12 | gem "standard", "~> 1.35.0"
13 |
--------------------------------------------------------------------------------
/examples/rails_app/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | gem "rails"
6 | gem "rspec"
7 | gem "rspec-rails"
8 | gem "skooma", (ENV["CI"] == "1") ? {path: File.join(__dir__, "..", "..")} : {}
9 | gem "sinatra"
10 |
--------------------------------------------------------------------------------
/lib/skooma/matchers/conform_schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Matchers
5 | class ConformSchema < ConformResponseSchema
6 | def description
7 | "conform schema with #{@expected} response code"
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/xml.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | class Xml < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "xml"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/tags.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Tags < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "tags"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/style.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Style < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "style"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/info.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class Info < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "info"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/in.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class In < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "in"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/security.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Security < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "security"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/servers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Servers < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "servers"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/summary.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Summary < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "summary"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/name.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class Name < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "name"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/response/keywords/links.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Response
6 | module Keywords
7 | class Links < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "links"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "skooma"
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | require "irb"
11 | IRB.start(__FILE__)
12 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/example.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | class Example < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "example"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/example.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Example < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "example"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/examples.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Examples < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "examples"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/explode.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Explode < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "explode"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/deprecated.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Deprecated < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "deprecated"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base/keywords/description.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base
6 | module Keywords
7 | class Description < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "description"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/security.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class Security < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "security"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/media_type.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # https://spec.openapis.org/oas/v3.1.0#media-type-object
6 | class MediaType < Base
7 | def kw_classes
8 | [
9 | Keywords::OAS31::Schema
10 | ]
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation/keywords/callbacks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Operation
6 | module Keywords
7 | class Callbacks < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "callbacks"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/external_docs.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | class ExternalDocs < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "externalDocs"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation/keywords/operation_id.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Operation
6 | module Keywords
7 | class OperationId < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "operationId"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/callback.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # https://spec.openapis.org/oas/v3.1.0#callback-object
6 | # A map of possible out-of band callbacks related to the parent operation.
7 | class Callback < RefBase
8 | def kw_classes
9 | end
10 | end
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/allow_reserved.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class AllowReserved < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "allowReserved"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/allow_empty_value.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class AllowEmptyValue < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "allowEmptyValue"
9 | end
10 | end
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/lib/skooma/validators/float.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Validators
5 | class Float < JSONSkooma::Validators::Base
6 | self.instance_types = "number"
7 |
8 | def call(instance)
9 | return if instance.value.is_a?(::Float)
10 |
11 | raise JSONSkooma::Validators::FormatError, "must be a valid float"
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/skooma/validators/double.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Validators
5 | class Double < JSONSkooma::Validators::Base
6 | self.instance_types = "number"
7 |
8 | def call(instance)
9 | return if instance.value.is_a?(::Float)
10 |
11 | raise JSONSkooma::Validators::FormatError, "must be a valid double"
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/examples/rails_app/spec/requests/openapi_spec.rb:
--------------------------------------------------------------------------------
1 | require "rails_helper"
2 |
3 | describe "OpenAPI documents", type: :request do
4 | describe "Bar API", :bar_api do
5 | subject(:schema) { skooma_openapi_schema }
6 |
7 | it { is_expected.to be_valid_document }
8 | end
9 |
10 | describe "Baz API", :baz_api do
11 | subject(:schema) { skooma_openapi_schema }
12 |
13 | it { is_expected.to be_valid_document }
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/get.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Get < BaseOperation
8 | self.key = "get"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/head.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Head < BaseOperation
8 | self.key = "head"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/post.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Post < BaseOperation
8 | self.key = "post"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/put.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Put < BaseOperation
8 | self.key = "put"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "skooma"
4 |
5 | RSpec.configure do |config|
6 | # Enable flags like --only-failures and --next-failure
7 | config.example_status_persistence_file_path = ".rspec_status"
8 |
9 | # Disable RSpec exposing methods globally on `Module` and `main`
10 | config.disable_monkey_patching!
11 |
12 | config.expect_with :rspec do |c|
13 | c.syntax = :expect
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/patch.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Patch < BaseOperation
8 | self.key = "patch"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/trace.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Trace < BaseOperation
8 | self.key = "trace"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/delete.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Delete < BaseOperation
8 | self.key = "delete"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/options.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Options < BaseOperation
8 | self.key = "options"
9 | self.depends_on = %w[parameters]
10 | self.value_schema = :schema
11 | self.schema_value_class = Objects::Operation
12 | end
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Schema < Skooma::Keywords::OAS31::Schema
8 | self.key = "schema"
9 |
10 | def evaluate(instance, result)
11 | return if instance.value.nil?
12 |
13 | super
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/skooma/inflector.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | class Inflector < Zeitwerk::GemInflector
5 | STATIC_MAPPING = {
6 | "oas_3_1" => "OAS31",
7 | "openapi" => "OpenAPI",
8 | "rspec" => "RSpec"
9 | }
10 |
11 | def camelize(basename, _abspath)
12 | if basename.include?("json_")
13 | super.gsub("Json", "JSON")
14 | else
15 | STATIC_MAPPING[basename] || super
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/skooma/objects/request_body.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # Describes a single request body.
6 | # https://spec.openapis.org/oas/v3.1.0#request-body-object
7 | class RequestBody < RefBase
8 | def kw_classes
9 | [
10 | Base::Keywords::Description,
11 | Skooma::Objects::Response::Keywords::Content,
12 | Keywords::Required
13 | ]
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/skooma/validators/int_32.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Validators
5 | class Int32 < JSONSkooma::Validators::Base
6 | def self.assert?(instance)
7 | instance.type == "number" && instance == instance.to_i
8 | end
9 |
10 | def call(instance)
11 | return if instance.value.bit_length <= 32
12 |
13 | raise JSONSkooma::Validators::FormatError, "must be a valid int32"
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/skooma/validators/int_64.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Validators
5 | class Int64 < JSONSkooma::Validators::Base
6 | def self.assert?(instance)
7 | instance.type == "number" && instance == instance.to_i
8 | end
9 |
10 | def call(instance)
11 | return if instance.value.bit_length <= 64
12 |
13 | raise JSONSkooma::Validators::FormatError, "must be a valid int64"
14 | end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/skooma/objects/response.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # Describes a single response from an API Operation.
6 | # https://spec.openapis.org/oas/v3.1.0#responseObject
7 | class Response < RefBase
8 | def kw_classes
9 | [
10 | Base::Keywords::Description,
11 | Keywords::Headers,
12 | Keywords::Content,
13 | Keywords::Links
14 | ]
15 | end
16 | end
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/webhooks.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | # Map[string, Path Item Object | Reference Object] ]
8 | # example: https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.1/webhook-example.yaml
9 | class Webhooks < JSONSkooma::Keywords::BaseAnnotation
10 | self.key = "webhooks"
11 | end
12 | end
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/required.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Required < JSONSkooma::Keywords::Base
8 | self.key = "required"
9 |
10 | def evaluate(instance, result)
11 | if json.value && instance.value.nil?
12 | result.failure("Header is required")
13 | end
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/skooma/objects/request_body/keywords/required.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class RequestBody
6 | module Keywords
7 | class Required < JSONSkooma::Keywords::Base
8 | self.key = "required"
9 |
10 | def evaluate(instance, result)
11 | if json.value && instance["body"].value.nil?
12 | result.failure("Body is required")
13 | end
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/parameters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class Parameters < JSONSkooma::Keywords::Base
8 | self.key = "parameters"
9 | self.value_schema = :array_of_schemas
10 | self.schema_value_class = Objects::Parameter
11 |
12 | def evaluate(instance, result)
13 | result.skip_assertion
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/examples/rails_app/app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails"
4 | require "action_controller/railtie"
5 | require "rails/test_unit/railtie"
6 |
7 | require_relative "../test_app"
8 |
9 | class RailsApp < Rails::Application
10 | config.load_defaults Rails::VERSION::STRING.to_f
11 | config.eager_load = false
12 | config.logger = Logger.new(nil)
13 |
14 | routes.append do
15 | mount TestApp["bar"], at: "/bar", as: :bar_api
16 | mount TestApp["baz"], at: "/baz", as: :baz_api
17 | end
18 | end
19 |
20 | RailsApp.initialize!
21 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/json_schema_dialect.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class JSONSchemaDialect < JSONSkooma::Keywords::Base
8 | self.key = "jsonSchemaDialect"
9 | self.static = true
10 |
11 | def initialize(parent_schema, value)
12 | super
13 |
14 | uri = URI.parse(value)
15 | parent_schema.json_schema_dialect_uri = uri
16 | end
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class Schema < Skooma::Keywords::OAS31::Schema
8 | self.key = "schema"
9 | self.depends_on = %w[in name style explode allowReserved allowEmptyValue]
10 |
11 | def evaluate(instance, result)
12 | value = ValueParser.call(instance, result)
13 | return result.discard if value.nil?
14 |
15 | super(value, result)
16 | end
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation/keywords/request_body.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Operation
6 | module Keywords
7 | class RequestBody < JSONSkooma::Keywords::Base
8 | self.key = "requestBody"
9 | self.value_schema = :schema
10 | self.schema_value_class = Objects::RequestBody
11 |
12 | def evaluate(instance, result)
13 | return result.discard unless instance.key?("request")
14 |
15 | json.evaluate(instance["request"], result)
16 | end
17 | end
18 | end
19 | end
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/required.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class Required < JSONSkooma::Keywords::Base
8 | self.key = "required"
9 | self.depends_on = %w[in name style explode allowReserved allowEmptyValue]
10 |
11 | def evaluate(instance, result)
12 | if json.value && ValueParser.call(instance, result)&.value.nil?
13 | result.failure("Parameter is required")
14 | end
15 | end
16 | end
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/components.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class Components < JSONSkooma::Keywords::Base
8 | self.key = "components"
9 | self.value_schema = :schema
10 | self.schema_value_class = Objects::Components
11 |
12 | def each_schema(&block)
13 | return super unless json.type == "object"
14 |
15 | json.each_value do |s|
16 | s.each_value(&block)
17 | end
18 | end
19 | end
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/examples/test_app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "sinatra"
4 | require "json"
5 |
6 | module TestApp
7 | def self.[](value)
8 | Sinatra.new do
9 | get "/" do
10 | content_type :json
11 | JSON.generate({"foo" => value})
12 | end
13 |
14 | post "/" do
15 | content_type :json
16 | data = JSON.parse request.body.read
17 | if data["foo"] == value
18 | status 201
19 | JSON.generate({"foo" => value})
20 | else
21 | status 400
22 | JSON.generate({"message" => "foo must be #{value}"})
23 | end
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # https://spec.openapis.org/oas/v3.1.0#header-object
6 | # The Header Object follows the structure of the Parameter Object.
7 | class Header < RefBase
8 | def kw_classes
9 | [
10 | Base::Keywords::Description,
11 | Base::Keywords::Deprecated,
12 | Keywords::Style,
13 | Keywords::Explode,
14 | Keywords::Required,
15 | Keywords::Schema,
16 | Keywords::Example,
17 | Keywords::Examples,
18 | Keywords::Content
19 | ]
20 | end
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/content.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Parameter
6 | module Keywords
7 | class Content < Header::Keywords::Content
8 | self.key = "content"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::MediaType
11 | self.depends_on = %w[in name style explode allowReserved allowEmptyValue]
12 |
13 | def evaluate(instance, result)
14 | return if instance.value.nil?
15 |
16 | super(ValueParser.call(instance, result), result)
17 | end
18 | end
19 | end
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module SchemaValue
7 | def wrap_value(value)
8 | return super unless value.is_a?(Hash) || value.is_a?(TrueClass) || value.is_a?(FalseClass)
9 |
10 | Objects::OpenAPI.new(value, registry: parent_schema.registry, parent: parent_schema, key: key)
11 | end
12 |
13 | def each_schema
14 | return super unless json.is_a?(Objects::OpenAPI)
15 |
16 | yield json
17 | end
18 | end
19 | JSONSkooma::Keywords::ValueSchemas.register_value_schema(:openapi_schema, SchemaValue)
20 | end
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/examples/rails_app/spec/requests/bar/bar_spec.rb:
--------------------------------------------------------------------------------
1 | require "rails_helper"
2 |
3 | describe "Bar API", :bar_api, type: :request do
4 | describe "GET /bar" do
5 | subject { get "/bar" }
6 |
7 | it { is_expected.to conform_schema(200) }
8 |
9 | it "returns correct response" do
10 | subject
11 | expect(response.parsed_body).to eq({"foo" => "bar"})
12 | end
13 | end
14 |
15 | describe "POST /bar" do
16 | subject { post("/bar", params: body, as: :json) }
17 |
18 | let(:body) { {foo: "bar"} }
19 |
20 | it { is_expected.to conform_schema(201) }
21 |
22 | context "with invalid params" do
23 | let(:body) { {foo: "baz"} }
24 |
25 | it { is_expected.to conform_response_schema(400) }
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/skooma/objects/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Base < JSONSkooma::JSONSchema
6 | DEFAULT_OPTIONS = {
7 | registry: REGISTRY_NAME
8 | }.freeze
9 |
10 | def initialize(value, **options)
11 | super(value, **DEFAULT_OPTIONS.merge(options))
12 | end
13 |
14 | def bootstrap(value)
15 | # nothing to do
16 | end
17 |
18 | def kw_classes
19 | []
20 | end
21 |
22 | def json_schema_dialect_uri
23 | root.json_schema_dialect_uri
24 | end
25 |
26 | private
27 |
28 | def kw_class(k)
29 | kw_classes.find { |kw| kw.key == k } || JSONSkooma::Keywords::Unknown[k]
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # Describes the operations available on a single path.
6 | # https://spec.openapis.org/oas/v3.1.0#path-item-object
7 | class PathItem < RefBase
8 | def kw_classes
9 | [
10 | Base::Keywords::Summary,
11 | Base::Keywords::Description,
12 |
13 | Keywords::Get,
14 | Keywords::Put,
15 | Keywords::Post,
16 | Keywords::Delete,
17 | Keywords::Options,
18 | Keywords::Head,
19 | Keywords::Patch,
20 | Keywords::Trace,
21 |
22 | Base::Keywords::Servers,
23 | Keywords::Parameters
24 | ]
25 | end
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/examples/rails_app/spec/requests/baz/baz_spec.rb:
--------------------------------------------------------------------------------
1 | require "rails_helper"
2 |
3 | describe "Baz API", :baz_api, type: :request do
4 | describe "GET prefixed /baz" do
5 | subject { get "/baz" }
6 |
7 | it { is_expected.to conform_schema(200) }
8 |
9 | it "returns correct response" do
10 | subject
11 | expect(response.parsed_body).to eq({"foo" => "baz"})
12 | end
13 | end
14 |
15 | describe "POST prefixed /baz" do
16 | subject { post("/baz", params: body, as: :json) }
17 |
18 | let(:body) { {foo: "baz"} }
19 |
20 | it { is_expected.to conform_schema(201) }
21 |
22 | context "with invalid params" do
23 | let(:body) { {foo: "bar"} }
24 |
25 | it { is_expected.to conform_response_schema(400) }
26 | end
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/openapi.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class OpenAPI < JSONSkooma::Keywords::BaseAnnotation
8 | self.key = "openapi"
9 |
10 | def initialize(parent_schema, value)
11 | unless value.to_s.start_with? "3.1."
12 | raise Error, "Only OpenAPI version 3.1.x is supported, got #{value}"
13 | end
14 |
15 | parent_schema.metaschema_uri = "https://spec.openapis.org/oas/3.1/schema-base/2022-10-07"
16 | parent_schema.json_schema_dialect_uri = "https://spec.openapis.org/oas/3.1/dialect/base"
17 |
18 | super
19 | end
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/skooma/body_parsers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module BodyParsers
5 | class << self
6 | DEFAULT_PARSER = ->(body, **_options) { body }
7 |
8 | def [](media_type)
9 | parsers[media_type.to_s.strip.downcase] || DEFAULT_PARSER
10 | end
11 |
12 | attr_accessor :parsers
13 |
14 | def register(*media_types, parser)
15 | media_types.each do |media_type|
16 | parsers[media_type.to_s.strip.downcase] = parser
17 | end
18 | end
19 | end
20 | self.parsers = {}
21 |
22 | module JSONParser
23 | def self.call(body, **_options)
24 | JSON.parse(body)
25 | rescue JSON::ParserError
26 | body
27 | end
28 | end
29 | register "application/json", JSONParser
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # https://spec.openapis.org/oas/v3.1.0#parameter-object
6 | # Describes a single operation parameter.
7 | class Parameter < RefBase
8 | def kw_classes
9 | [
10 | Keywords::Name,
11 | Keywords::In,
12 | Base::Keywords::Description,
13 | Base::Keywords::Deprecated,
14 | Header::Keywords::Style,
15 | Header::Keywords::Explode,
16 | Keywords::AllowEmptyValue,
17 | Keywords::AllowReserved,
18 | Keywords::Required,
19 | Keywords::Schema,
20 | Header::Keywords::Example,
21 | Header::Keywords::Examples,
22 | Keywords::Content
23 | ]
24 | end
25 | end
26 | end
27 | end
28 |
--------------------------------------------------------------------------------
/data/oas-3.1/schema-base/2022-10-07.json:
--------------------------------------------------------------------------------
1 | {
2 | "$id": "https://spec.openapis.org/oas/3.1/schema-base/2022-10-07",
3 | "$schema": "https://json-schema.org/draft/2020-12/schema",
4 |
5 | "description": "The description of OpenAPI v3.1.x documents using the OpenAPI JSON Schema dialect, as defined by https://spec.openapis.org/oas/v3.1.0",
6 |
7 | "$ref": "https://spec.openapis.org/oas/3.1/schema/2022-10-07",
8 | "properties": {
9 | "jsonSchemaDialect": { "$ref": "#/$defs/dialect" }
10 | },
11 |
12 | "$defs": {
13 | "dialect": { "const": "https://spec.openapis.org/oas/3.1/dialect/base" },
14 |
15 | "schema": {
16 | "$dynamicAnchor": "meta",
17 | "$ref": "https://spec.openapis.org/oas/3.1/dialect/base",
18 | "properties": {
19 | "$schema": { "$ref": "#/$defs/dialect" }
20 | }
21 | }
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | class Schema < JSONSkooma::Keywords::Base
7 | self.key = "schema"
8 |
9 | def evaluate(instance, result)
10 | json.evaluate(instance.coerce(json), result)
11 | end
12 |
13 | def each_schema
14 | yield json
15 | end
16 |
17 | private
18 |
19 | def wrap_value(value)
20 | JSONSkooma::JSONSchema.new(
21 | value,
22 | key: key,
23 | parent: parent_schema,
24 | registry: parent_schema.registry,
25 | cache_id: parent_schema.cache_id,
26 | metaschema_uri: parent_schema.json_schema_dialect_uri
27 | )
28 | end
29 | end
30 | end
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/examples/rails_app/spec/rails_helper.rb:
--------------------------------------------------------------------------------
1 | ENV["RAILS_ENV"] = "test"
2 |
3 | require_relative "../app"
4 |
5 | require "rspec/rails"
6 | require "skooma"
7 |
8 | RSpec.configure do |config|
9 | config.expect_with :rspec do |expectations|
10 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true
11 | end
12 | config.shared_context_metadata_behavior = :apply_to_host_groups
13 |
14 | bar_openapi = File.join(__dir__, "..", "docs", "bar_openapi.yml")
15 | baz_openapi = File.join(__dir__, "..", "docs", "baz_openapi.yml")
16 |
17 | # You can use different RSpec filters if you want to test different API descriptions.
18 | # Check RSpec's config.define_derived_metadata for better UX.
19 | config.include Skooma::RSpec[bar_openapi, path_prefix: "/bar", coverage: :strict], :bar_api
20 | config.include Skooma::RSpec[baz_openapi, path_prefix: "/baz", coverage: :strict], :baz_api
21 | end
22 |
--------------------------------------------------------------------------------
/lib/skooma/matchers/conform_response_schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Matchers
5 | class ConformResponseSchema < ConformRequestSchema
6 | def initialize(skooma, mapped_response, expected)
7 | super(skooma, mapped_response)
8 | @expected = expected
9 | end
10 |
11 | def description
12 | "conform response schema with #{@expected} response code"
13 | end
14 |
15 | def matches?(*)
16 | return false unless status_matches?
17 |
18 | super
19 | end
20 |
21 | def failure_message
22 | return "Expected #{@expected} status code, but got #{@mapped_response["response"]["status"]}" unless status_matches?
23 |
24 | super
25 | end
26 |
27 | private
28 |
29 | def status_matches?
30 | @mapped_response["response"]["status"] == @expected
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/skooma.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "json_skooma"
4 | require "zeitwerk"
5 |
6 | require_relative "skooma/inflector"
7 |
8 | loader = Zeitwerk::Loader.for_gem
9 | loader.inflector = Skooma::Inflector.new(__FILE__)
10 |
11 | # Do not eager load the test helpers
12 | loader.do_not_eager_load(File.join(__dir__, "skooma", "minitest.rb"))
13 | loader.do_not_eager_load(File.join(__dir__, "skooma", "rspec.rb"))
14 |
15 | loader.setup
16 |
17 | module Skooma
18 | DATA_DIR = File.join(__dir__, "..", "data")
19 | REGISTRY_NAME = "skooma_registry"
20 |
21 | class Error < StandardError; end
22 |
23 | JSONSkooma.register_dialect("oas-3.1", Dialects::OAS31)
24 | JSONSkooma::Formatters.register :skooma, OutputFormat
25 |
26 | class << self
27 | def create_registry(name: REGISTRY_NAME)
28 | JSONSkooma.create_registry("2020-12", "oas-3.1", name: name, assert_formats: true)
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/skooma/matchers/be_valid_document.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pp"
4 |
5 | module Skooma
6 | module Matchers
7 | class BeValidDocument
8 | def matches?(actual)
9 | @actual = actual
10 | return false unless comparable?
11 |
12 | @result = @actual.validate
13 | @result.valid?
14 | end
15 |
16 | def description
17 | "be a valid OpenAPI document"
18 | end
19 |
20 | def failure_message
21 | return "expected value to be an OpenAPI object" unless comparable?
22 |
23 | <<~MSG
24 | must valid against OpenAPI specification:
25 | #{pretty(@result.output(:detailed))}
26 | MSG
27 | end
28 |
29 | private
30 |
31 | def pretty(result)
32 | PP.pp(result, +"")
33 | end
34 |
35 | def comparable?
36 | @actual.is_a?(Skooma::Objects::OpenAPI)
37 | end
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # Describes a single API operation on a path.
6 | # https://spec.openapis.org/oas/v3.1.0#operation-object
7 | class Operation < Base
8 | def kw_classes
9 | [
10 | Base::Keywords::Tags,
11 | Base::Keywords::Summary,
12 | Base::Keywords::Description,
13 | Skooma::Keywords::OAS31::Dialect::ExternalDocs,
14 | Keywords::OperationId,
15 | Keywords::Parameters,
16 | Keywords::RequestBody,
17 | Keywords::Responses,
18 | Keywords::Callbacks,
19 | Base::Keywords::Deprecated,
20 | Base::Keywords::Security,
21 | Base::Keywords::Servers
22 | ]
23 | end
24 |
25 | def bootstrap(value)
26 | # always evaluate parameters to check parent parameters
27 | add_keyword(Keywords::Parameters.new(self, value["parameters"] || []))
28 | end
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/lib/skooma/matchers/conform_request_schema.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pp"
4 |
5 | module Skooma
6 | module Matchers
7 | class ConformRequestSchema
8 | def initialize(skooma, mapped_response)
9 | @skooma = skooma
10 | @schema = skooma.schema
11 | @mapped_response = mapped_response
12 | end
13 |
14 | def matches?(*)
15 | @result = @schema.evaluate(@mapped_response)
16 |
17 | @skooma.coverage.track_request(@result) if @mapped_response["response"]
18 |
19 | @result.valid?
20 | end
21 |
22 | def description
23 | "conform request schema"
24 | end
25 |
26 | def failure_message
27 | <<~MSG
28 | ENV:
29 | #{pretty(@mapped_response)}
30 |
31 | Validation Result:
32 | #{pretty(@result.output(:skooma))}
33 | MSG
34 | end
35 |
36 | private
37 |
38 | def pretty(result)
39 | PP.pp(result, +"")
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/skooma/objects/ref_base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # A simple object to allow referencing other components in the OpenAPI document.
6 | # https://spec.openapis.org/oas/v3.1.0#referenceObject
7 | class RefBase < Base
8 | # This object cannot be extended with additional properties
9 | # and any properties added SHALL be ignored.
10 | def resolve_keywords(value)
11 | return super unless value.key?("$ref")
12 |
13 | resolve_ref_keywords(value)
14 | end
15 |
16 | def ref_kw_classes
17 | [
18 | JSONSkooma::Keywords::Core::Ref,
19 | Base::Keywords::Summary,
20 | Base::Keywords::Description
21 | ]
22 | end
23 |
24 | def resolve_ref_keywords(value)
25 | ref_kw_classes.each do |kw_class|
26 | next unless value.key?(kw_class.key)
27 |
28 | add_keyword(kw_class.new(self, value[kw_class.key]))
29 | end
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/skooma/output_format.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module OutputFormat
5 | class << self
6 | def call(result, **_options)
7 | return {"valid" => true} if result.valid?
8 |
9 | node_data(result, true)
10 | end
11 |
12 | private
13 |
14 | def node_data(node, first = false)
15 | data = {
16 | "instanceLocation" => node.instance.path.to_s,
17 | "relativeKeywordLocation" => node.relative_path.to_s,
18 | "keywordLocation" => node.path.to_s
19 | }
20 |
21 | child_data = []
22 | node.each_children do |child|
23 | child_data << node_data(child) unless child.valid?
24 | end
25 |
26 | if first || child_data.length > 1
27 | data["errors"] = child_data
28 | elsif child_data.length == 1
29 | data = child_data[0]
30 | elsif node.error
31 | data["error"] = node.error
32 | end
33 |
34 | data
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/skooma/objects/components.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # Holds a set of reusable objects for different aspects of the OAS.
6 | # https://spec.openapis.org/oas/v3.1.0#components-object
7 | class Components < JSONSkooma::JSONNode
8 | CLASSES_MAP = {
9 | "schemas" => JSONSkooma::JSONSchema,
10 | "responses" => Response,
11 | "parameters" => Parameter,
12 | # "examples" => Example,
13 | "requestBodies" => RequestBody,
14 | "headers" => Header,
15 | "securitySchemes" => JSONSkooma::JSONNode,
16 | "links" => JSONSkooma::JSONNode,
17 | # "callbacks" => Callback,
18 | "pathItems" => PathItem
19 | }
20 |
21 | def map_object_value(value)
22 | value.map do |k, v|
23 | key = k.to_s
24 | value = JSONSkooma::JSONNode.new(v, key: key, parent: self, item_class: CLASSES_MAP.fetch(key), **@item_params, metaschema_uri: parent.json_schema_dialect_uri)
25 | [key, value]
26 | end.to_h
27 | end
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/examples/minitest.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "test_app"
4 |
5 | require "minitest/autorun"
6 | require "rack/test"
7 | require "skooma"
8 |
9 | describe TestApp do
10 | include Rack::Test::Methods
11 |
12 | include Skooma::Minitest[File.join(__dir__, "openapi.yml"), coverage: :report]
13 |
14 | def app
15 | TestApp["bar"]
16 | end
17 |
18 | it "is valid OpenAPI document" do
19 | assert_is_valid_document(skooma_openapi_schema)
20 | end
21 |
22 | describe "GET /" do
23 | it "conforms to schema with 200 response code" do
24 | get "/"
25 | assert_conform_schema(200)
26 | end
27 | end
28 |
29 | describe "POST /" do
30 | it "conforms to schema with 201 response code" do
31 | post("/", JSON.generate("foo" => "bar"), "CONTENT_TYPE" => "application/json")
32 |
33 | assert_conform_schema(201)
34 | end
35 |
36 | it "conforms to schema with 400 response code" do
37 | post("/", JSON.generate("foo" => "baz"), "CONTENT_TYPE" => "application/json")
38 |
39 | assert_conform_response_schema(400)
40 | end
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/lib/skooma/objects/response/keywords/headers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Response
6 | module Keywords
7 | class Headers < JSONSkooma::Keywords::Base
8 | self.key = "headers"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::Header
11 |
12 | def evaluate(instance, result)
13 | errors = []
14 | json.each do |key, schema|
15 | next if ignored_key?(key)
16 |
17 | result.call(instance["headers"], key) do |subresult|
18 | schema.evaluate(instance["headers"][key], subresult)
19 |
20 | errors << key unless subresult.passed?
21 | end
22 | end
23 | return if errors.empty?
24 |
25 | result.failure("The following headers are invalid: #{errors}")
26 | end
27 |
28 | private
29 |
30 | def ignored_key?(key)
31 | %w[accept content-type authorization].include?(key.downcase)
32 | end
33 | end
34 | end
35 | end
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2023 Svyatoslav Kryukov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/data/oas-3.1/dialect/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$id": "https://spec.openapis.org/oas/3.1/dialect/base",
3 | "$schema": "https://json-schema.org/draft/2020-12/schema",
4 |
5 | "title": "OpenAPI 3.1 Schema Object Dialect",
6 | "description": "A JSON Schema dialect describing schemas found in OpenAPI documents",
7 |
8 | "$vocabulary": {
9 | "https://json-schema.org/draft/2020-12/vocab/core": true,
10 | "https://json-schema.org/draft/2020-12/vocab/applicator": true,
11 | "https://json-schema.org/draft/2020-12/vocab/unevaluated": true,
12 | "https://json-schema.org/draft/2020-12/vocab/validation": true,
13 | "https://json-schema.org/draft/2020-12/vocab/meta-data": true,
14 | "https://json-schema.org/draft/2020-12/vocab/format-annotation": true,
15 | "https://json-schema.org/draft/2020-12/vocab/content": true,
16 | "https://spec.openapis.org/oas/3.1/vocab/base": false
17 | },
18 |
19 | "$dynamicAnchor": "meta",
20 |
21 | "allOf": [
22 | { "$ref": "https://json-schema.org/draft/2020-12/schema" },
23 | { "$ref": "https://spec.openapis.org/oas/3.1/meta/base" }
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/examples/rspec.rb:
--------------------------------------------------------------------------------
1 | require_relative "test_app"
2 |
3 | require "rspec/autorun"
4 | require "rack/test"
5 | require "skooma"
6 |
7 | RSpec.configure do |config|
8 | path_to_openapi = File.join(__dir__, "openapi.yml")
9 | config.include Skooma::RSpec[path_to_openapi, coverage: :strict], type: :request
10 |
11 | config.include Rack::Test::Methods, type: :request
12 | end
13 |
14 | describe TestApp, type: :request do
15 | def app
16 | TestApp["bar"]
17 | end
18 |
19 | describe "OpenAPI document", type: :request do
20 | subject(:schema) { skooma_openapi_schema }
21 |
22 | it { is_expected.to be_valid_document }
23 | end
24 |
25 | describe "GET /" do
26 | subject { get "/" }
27 |
28 | it { is_expected.to conform_schema(200) }
29 | end
30 |
31 | describe "POST /" do
32 | subject { post("/", JSON.generate(body), "CONTENT_TYPE" => "application/json") }
33 |
34 | let(:body) { {foo: "bar"} }
35 |
36 | it { is_expected.to conform_schema(201) }
37 |
38 | context "with invalid params" do
39 | let(:body) { {foo: "baz"} }
40 |
41 | it { is_expected.to conform_response_schema(400) }
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/skooma/objects/path_item/keywords/base_operation.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class PathItem
6 | module Keywords
7 | class BaseOperation < JSONSkooma::Keywords::Base
8 | def evaluate(instance, result)
9 | return result.discard unless instance["method"] == key
10 |
11 | if json["responses"].nil? && instance["response"]
12 | return result.failure("Responses are not listed for #{key.upcase} #{instance["path"]}")
13 | end
14 |
15 | json.evaluate(instance, result)
16 |
17 | path_item_result = result.parent
18 | path_item_result = path_item_result.parent until path_item_result.key.start_with?("/")
19 |
20 | paths_result = path_item_result.parent
21 | paths_result.annotate(paths_result.annotation.merge("method" => key))
22 |
23 | return result.success if result.passed?
24 |
25 | path = paths_result.annotation["current_path"]
26 |
27 | result.failure("Path #{path}/#{key} is invalid")
28 | end
29 | end
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/any_of.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | class AnyOf < JSONSkooma::Keywords::Applicator::AnyOf
8 | self.key = "anyOf"
9 | self.value_schema = :array_of_schemas
10 | self.depends_on = %w[discriminator]
11 |
12 | def evaluate(instance, result)
13 | discriminator_schema = result.sibling(instance, "discriminator")&.annotation
14 | reorder_json(discriminator_schema)
15 |
16 | super
17 | end
18 |
19 | private
20 |
21 | def reorder_json(discriminator_schema)
22 | return unless discriminator_schema
23 |
24 | first = @json.delete_at(@json.index { |schema| resolve_uri(schema["$ref"]) == discriminator_schema })
25 | @json.unshift first if first
26 | end
27 |
28 | def resolve_uri(uri)
29 | uri = URI.parse(uri)
30 | return uri if uri.absolute?
31 |
32 | parent_schema.base_uri + uri if parent_schema.base_uri
33 | end
34 | end
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/one_of.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | class OneOf < JSONSkooma::Keywords::Applicator::OneOf
8 | self.key = "oneOf"
9 | self.value_schema = :array_of_schemas
10 | self.depends_on = %w[discriminator]
11 |
12 | def evaluate(instance, result)
13 | discriminator_schema = result.sibling(instance, "discriminator")&.annotation
14 | reorder_json(discriminator_schema)
15 |
16 | super
17 | end
18 |
19 | private
20 |
21 | def reorder_json(discriminator_schema)
22 | return unless discriminator_schema
23 |
24 | first = @json.delete_at(@json.index { |schema| resolve_uri(schema["$ref"]) == discriminator_schema })
25 | @json.unshift first if first
26 | end
27 |
28 | def resolve_uri(uri)
29 | uri = URI.parse(uri)
30 | return uri if uri.absolute?
31 |
32 | parent_schema.base_uri + uri if parent_schema.base_uri
33 | end
34 | end
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/skooma.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/skooma/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "skooma"
7 | spec.version = Skooma::VERSION
8 | spec.authors = ["Svyatoslav Kryukov"]
9 | spec.email = ["me@skryukov.dev"]
10 |
11 | spec.summary = "Validate API implementations against OpenAPI documents."
12 | spec.description = "Apply a documentation-first approach to API development."
13 | spec.homepage = "https://github.com/skryukov/skooma"
14 | spec.license = "MIT"
15 | spec.required_ruby_version = ">= 2.6.0"
16 |
17 | spec.metadata = {
18 | "bug_tracker_uri" => "#{spec.homepage}/issues",
19 | "changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
20 | "documentation_uri" => "#{spec.homepage}/blob/main/README.md",
21 | "homepage_uri" => spec.homepage,
22 | "source_code_uri" => spec.homepage,
23 | "rubygems_mfa_required" => "true"
24 | }
25 |
26 | spec.files = Dir.glob("lib/**/*") + Dir.glob("data/**/*") + %w[README.md LICENSE.txt CHANGELOG.md]
27 | spec.require_paths = ["lib"]
28 |
29 | spec.add_runtime_dependency "zeitwerk", "~> 2.6"
30 | spec.add_runtime_dependency "json_skooma", "~> 0.2.0"
31 | end
32 |
--------------------------------------------------------------------------------
/lib/skooma/rspec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | # RSpec matchers for OpenAPI schema validation
5 | # @example
6 | # RSpec.configure do |config|
7 | # # ...
8 | # config.include Skooma::RSpec[Rails.root.join("docs", "openapi.yml")], type: :request
9 | # end
10 | class RSpec < Matchers::Wrapper
11 | module HelperMethods
12 | def conform_schema(expected_status)
13 | Matchers::ConformSchema.new(skooma, mapped_response, expected_status)
14 | end
15 |
16 | def conform_response_schema(expected_status)
17 | Matchers::ConformResponseSchema.new(skooma, mapped_response(with_request: false), expected_status)
18 | end
19 |
20 | def conform_request_schema
21 | Matchers::ConformRequestSchema.new(skooma, mapped_response(with_response: false))
22 | end
23 |
24 | def be_valid_document
25 | Matchers::BeValidDocument.new
26 | end
27 | end
28 |
29 | def initialize(openapi_path, **params)
30 | super(HelperMethods, openapi_path, **params)
31 |
32 | skooma_self = self
33 | ::RSpec.configure do |c|
34 | c.after(:suite) do
35 | at_exit { skooma_self.coverage.report }
36 | end
37 | end
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/examples/openapi.yml:
--------------------------------------------------------------------------------
1 | openapi: 3.1.0
2 | info:
3 | title: OpenAPI Sample
4 | version: 1.0.0
5 |
6 | paths:
7 | "/":
8 | get:
9 | responses:
10 | "200":
11 | description: OK
12 | content:
13 | application/json:
14 | schema:
15 | $ref: "#/components/schemas/Item"
16 |
17 | post:
18 | requestBody:
19 | content:
20 | application/json:
21 | schema:
22 | $ref: "#/components/schemas/Item"
23 | responses:
24 | "201":
25 | description: OK
26 | content:
27 | application/json:
28 | schema:
29 | $ref: "#/components/schemas/Item"
30 | "400":
31 | description: Bad Request
32 | content:
33 | application/json:
34 | schema:
35 | $ref: "#/components/schemas/Error"
36 |
37 | components:
38 | schemas:
39 | Item:
40 | type: object
41 | unevaluatedProperties: false
42 | required: [foo]
43 | properties:
44 | foo:
45 | type: string
46 | enum: [bar]
47 | Error:
48 | type: object
49 | unevaluatedProperties: false
50 | required: [message]
51 | properties:
52 | message:
53 | type: string
54 |
--------------------------------------------------------------------------------
/examples/rails_app/docs/bar_openapi.yml:
--------------------------------------------------------------------------------
1 | openapi: 3.1.0
2 | info:
3 | title: OpenAPI Sample
4 | version: 1.0.0
5 |
6 | paths:
7 | "/":
8 | get:
9 | responses:
10 | "200":
11 | description: OK
12 | content:
13 | application/json:
14 | schema:
15 | $ref: "#/components/schemas/Item"
16 |
17 | post:
18 | requestBody:
19 | content:
20 | application/json:
21 | schema:
22 | $ref: "#/components/schemas/Item"
23 | responses:
24 | "201":
25 | description: OK
26 | content:
27 | application/json:
28 | schema:
29 | $ref: "#/components/schemas/Item"
30 | "400":
31 | description: Bad Request
32 | content:
33 | application/json:
34 | schema:
35 | $ref: "#/components/schemas/Error"
36 |
37 | components:
38 | schemas:
39 | Item:
40 | type: object
41 | unevaluatedProperties: false
42 | required: [foo]
43 | properties:
44 | foo:
45 | type: string
46 | enum: [bar]
47 | Error:
48 | type: object
49 | unevaluatedProperties: false
50 | required: [message]
51 | properties:
52 | message:
53 | type: string
54 |
--------------------------------------------------------------------------------
/examples/rails_app/docs/baz_openapi.yml:
--------------------------------------------------------------------------------
1 | openapi: 3.1.0
2 | info:
3 | title: OpenAPI Sample
4 | version: 1.0.0
5 |
6 | paths:
7 | "/":
8 | get:
9 | responses:
10 | "200":
11 | description: OK
12 | content:
13 | application/json:
14 | schema:
15 | $ref: "#/components/schemas/Item"
16 |
17 | post:
18 | requestBody:
19 | content:
20 | application/json:
21 | schema:
22 | $ref: "#/components/schemas/Item"
23 | responses:
24 | "201":
25 | description: OK
26 | content:
27 | application/json:
28 | schema:
29 | $ref: "#/components/schemas/Item"
30 | "400":
31 | description: Bad Request
32 | content:
33 | application/json:
34 | schema:
35 | $ref: "#/components/schemas/Error"
36 |
37 | components:
38 | schemas:
39 | Item:
40 | type: object
41 | unevaluatedProperties: false
42 | required: [foo]
43 | properties:
44 | foo:
45 | type: string
46 | enum: [baz]
47 | Error:
48 | type: object
49 | unevaluatedProperties: false
50 | required: [message]
51 | properties:
52 | message:
53 | type: string
54 |
--------------------------------------------------------------------------------
/examples/rails_app/README.md:
--------------------------------------------------------------------------------
1 | # RSpec Rails Example
2 |
3 | This is an example Rails app that uses Skooma with multiple openapi documents.
4 |
5 | First, we need to define the OpenAPI documents we want to use:
6 |
7 | ```ruby
8 | # rails_helper.rb
9 | RSpec.configure do |config|
10 | # You can use different RSpec filters if you want to test different API descriptions.
11 | # Check RSpec's config.define_derived_metadata for better UX.
12 | config.include Skooma::RSpec[bar_openapi, path_prefix: "/bar"], :bar_api
13 | config.include Skooma::RSpec[baz_openapi, path_prefix: "/baz"], :baz_api
14 | end
15 | ```
16 |
17 | Next, we can write our specs and mark them with the appropriate RSpec filter:
18 |
19 | ```ruby
20 | # spec/requests/bar/bar_spec.rb
21 | describe "Bar API", :bar_api, type: :request do
22 | describe "GET /bar" do
23 | subject { get "/bar" }
24 |
25 | it { is_expected.to conform_schema(200) }
26 | end
27 | end
28 | ```
29 |
30 | To avoid having to specify the RSpec filter on every spec, you can use RSpec's `config.define_derived_metadata`:
31 |
32 | ```ruby
33 | # rails_helper.rb
34 | RSpec.configure do |config|
35 | config.define_derived_metadata(file_path: %r{/spec/requests/bar}) do |metadata|
36 | metadata[:bar_api] = true
37 | end
38 | end
39 | ```
40 |
41 | ## Running the example
42 |
43 | ```bash
44 | bundle install
45 | bundle exec rspec
46 | ```
47 |
--------------------------------------------------------------------------------
/spec/skooma_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Skooma do
4 | before(:all) { Skooma.create_registry }
5 |
6 | Dir["#{File.expand_path("../openapi_test_suite", __FILE__)}/*.json"].each do |file|
7 | JSON.parse(File.read(file)).each do |test_case|
8 | context test_case["description"] do
9 | let(:schema) do
10 | Skooma::Objects::OpenAPI.new(test_case["schema"])
11 | end
12 |
13 | it "contains a valid openapi schema" do
14 | result = schema.validate
15 | expect(result).to be_valid, <<~MSG
16 | Expected given schema to be valid.
17 |
18 | Schema:
19 | #{JSON.pretty_generate(test_case["schema"])}
20 |
21 | Validation output:
22 | #{JSON.pretty_generate(result.output(:detailed))}
23 | MSG
24 | end
25 |
26 | test_case["tests"].each do |test|
27 | it test["description"] do
28 | result = schema.evaluate(test["data"])
29 |
30 | expect(result.valid?).to eq(test["valid"]), <<~MSG
31 | Expected given response to be #{test["valid"] ? "valid" : "invalid"}.
32 |
33 | Response:
34 | #{JSON.pretty_generate(test["data"])}
35 |
36 | Validation output:
37 | #{JSON.pretty_generate(result.output(:detailed))}
38 | MSG
39 | end
40 | end
41 | end
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/skooma/minitest.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "minitest/unit"
4 |
5 | module Skooma
6 | # Minitest helpers for OpenAPI schema validation
7 | # @example
8 | # describe TestApp do
9 | # include Skooma::Minitest[Rails.root.join("docs", "openapi.yml")]
10 | # # ...
11 | # end
12 | class Minitest < Matchers::Wrapper
13 | module HelperMethods
14 | def assert_conform_schema(expected_status)
15 | matcher = Matchers::ConformSchema.new(skooma, mapped_response, expected_status)
16 |
17 | assert matcher.matches?, -> { matcher.failure_message }
18 | end
19 |
20 | def assert_conform_request_schema
21 | matcher = Matchers::ConformRequestSchema.new(skooma, mapped_response(with_response: false))
22 |
23 | assert matcher.matches?, -> { matcher.failure_message }
24 | end
25 |
26 | def assert_conform_response_schema(expected_status)
27 | matcher = Matchers::ConformResponseSchema.new(skooma, mapped_response(with_request: false), expected_status)
28 |
29 | assert matcher.matches?, -> { matcher.failure_message }
30 | end
31 |
32 | def assert_is_valid_document(document)
33 | matcher = Matchers::BeValidDocument.new
34 |
35 | assert matcher.matches?(document), -> { matcher.failure_message }
36 | end
37 | end
38 |
39 | def initialize(openapi_path, **params)
40 | super(HelperMethods, openapi_path, **params)
41 |
42 | ::Minitest.after_run { coverage.report }
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/lib/skooma/keywords/oas_3_1/dialect/discriminator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Keywords
5 | module OAS31
6 | module Dialect
7 | # Discriminator keyword is an annotation keyword,
8 | # it does not affect validation of allOf/anyOf/oneOf schemas.
9 | # See https://github.com/OAI/OpenAPI-Specification/pull/2618
10 | class Discriminator < JSONSkooma::Keywords::Base
11 | self.key = "discriminator"
12 |
13 | def evaluate(instance, result)
14 | value = instance[json["propertyName"]]
15 | uri = mapped_uri(value)
16 | return result.failure("Could not resolve discriminator for value `#{value.inspect}`") if uri.nil?
17 |
18 | parent_schema.registry.schema(
19 | uri,
20 | metaschema_uri: parent_schema.metaschema_uri,
21 | cache_id: parent_schema.cache_id
22 | )
23 | result.annotate(uri)
24 | rescue JSONSkooma::RegistryError => e
25 | result.failure("Could not resolve discriminator mapping: #{e.message}")
26 | end
27 |
28 | private
29 |
30 | def mapped_uri(value)
31 | uri = json["mapping"]&.fetch(value, value)
32 | return if uri.nil?
33 |
34 | uri = "#/components/schemas/#{uri}" unless uri.start_with?("#") || uri.include?("/")
35 | uri = URI.parse(uri)
36 | return uri if uri.absolute?
37 |
38 | parent_schema.base_uri + uri if parent_schema.base_uri
39 | end
40 | end
41 | end
42 | end
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation/keywords/responses.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Operation
6 | module Keywords
7 | class Responses < JSONSkooma::Keywords::Base
8 | self.key = "responses"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::Response
11 |
12 | def evaluate(instance, result)
13 | response = instance["response"] || {}
14 | return result.discard unless response["status"]
15 |
16 | status = find_status(response)
17 |
18 | return result.failure("Status #{response["status"]} not found for #{instance["method"].upcase} #{instance["path"]}") unless status
19 |
20 | result.annotate(status)
21 | result.call(response["status"], status) do |status_result|
22 | json[status].evaluate(response, status_result)
23 |
24 | if status_result.passed?
25 | result.annotate(status)
26 | else
27 | result.failure("Res #{response["status"]} is invalid")
28 | end
29 | end
30 | end
31 |
32 | private
33 |
34 | def find_status(response)
35 | response_status = response["status"].to_s
36 | if json.key?(response_status)
37 | response_status
38 | elsif json.key?("#{response_status[0]}XX")
39 | "#{response_status[0]}XX"
40 | elsif json.key?("default")
41 | "default"
42 | end
43 | end
44 | end
45 | end
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | # OpenAPI Object – the root object of the OpenAPI document.
6 | # https://spec.openapis.org/oas/v3.1.0#openapi-object
7 | class OpenAPI < Base
8 | attr_writer :json_schema_dialect_uri
9 |
10 | def kw_classes
11 | [
12 | Keywords::Info,
13 | Keywords::JSONSchemaDialect,
14 | Keywords::Paths,
15 | Keywords::Webhooks,
16 | Keywords::Components,
17 | Base::Keywords::Servers,
18 | Base::Keywords::Security,
19 | Base::Keywords::Tags,
20 | Skooma::Keywords::OAS31::Dialect::ExternalDocs
21 | ]
22 | end
23 |
24 | def bootstrap(value)
25 | # always evaluate openapi to check version,
26 | # and set metaschema_uri and json_schema_dialect_uri
27 | add_keyword(Keywords::OpenAPI.new(self, value["openapi"]))
28 | end
29 |
30 | def evaluate(instance, result = nil)
31 | super(Instance.new(instance), result)
32 | end
33 |
34 | def path_prefix=(value)
35 | raise ArgumentError, "Path prefix must be a string" unless value.is_a?(String)
36 |
37 | @path_prefix = value
38 | @path_prefix = "/#{@path_prefix}" unless @path_prefix.start_with?("/")
39 | @path_prefix = @path_prefix.delete_suffix("/") if @path_prefix.end_with?("/")
40 | end
41 |
42 | def path_prefix
43 | @path_prefix || ""
44 | end
45 |
46 | def json_schema_dialect_uri
47 | @json_schema_dialect_uri || parent_schema&.json_schema_dialect_uri
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/skooma/env_mapper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module EnvMapper
5 | class << self
6 | PLAIN_HEADERS = %w[CONTENT_LENGTH CONTENT_TYPE].freeze
7 | REGEXP_HTTP = /^HTTP_/.freeze
8 |
9 | def call(env, response = nil, with_response: true, with_request: true)
10 | result = {
11 | "method" => env["REQUEST_METHOD"].downcase,
12 | "path" => env["action_dispatch.original_path"] || env["PATH_INFO"]
13 | }
14 | result["request"] = map_request(env) if with_request
15 | result["response"] = map_response(response) if response && with_response
16 |
17 | result
18 | end
19 |
20 | private
21 |
22 | def map_request(env)
23 | {
24 | "query" => env["rack.request.query_string"] || env["QUERY_STRING"],
25 | "headers" => env.select { |k, _| k.start_with?("HTTP_") || PLAIN_HEADERS.include?(k) }.transform_keys { |k| k.sub(REGEXP_HTTP, "").split("_").map(&:capitalize).join("-") },
26 | "body" => env["RAW_POST_DATA"] || read_rack_input(env["rack.input"])
27 | }
28 | end
29 |
30 | def read_rack_input(input)
31 | return nil unless input.respond_to?(:rewind)
32 |
33 | input.rewind
34 | raw_input = input.read
35 | input.rewind
36 | raw_input
37 | end
38 |
39 | def map_response(response)
40 | status, headers, body = response.to_a
41 | full_body = +""
42 | body.each { |chunk| full_body << chunk }
43 | {
44 | "status" => status,
45 | "headers" => headers.to_h,
46 | "body" => full_body
47 | }
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/skooma/objects/header/keywords/content.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Header
6 | module Keywords
7 | class Content < JSONSkooma::Keywords::Base
8 | self.key = "content"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::MediaType
11 |
12 | def evaluate(instance, result)
13 | return if instance&.value.nil?
14 |
15 | media_type = result.root.instance["response"]["headers"]["Content-Type"]&.split(";")&.first&.strip&.downcase
16 | media_type_object, matched_media_type = find_media_type(media_type)
17 |
18 | return result.discard unless media_type_object
19 |
20 | result.annotate(matched_media_type)
21 | result.call(instance, matched_media_type) do |media_type_result|
22 | media_type_object.evaluate(instance, media_type_result)
23 | result.failure("Invalid content") unless media_type_result.passed?
24 | end
25 | end
26 |
27 | private
28 |
29 | # The key is a media type or media type range and the value describes it.
30 | # For requests that match multiple keys, only the most specific key is applicable.
31 | # e.g. text/plain overrides text/*
32 | def find_media_type(media_type)
33 | matched_media_type =
34 | if json.key?(media_type)
35 | media_type
36 | elsif media_type &&
37 | (key = "#{media_type.split("/").first}/*") &&
38 | json.key?(key)
39 | key
40 | elsif json.key?("*/*")
41 | "*/*"
42 | end
43 |
44 | [json[matched_media_type], matched_media_type]
45 | end
46 | end
47 | end
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Ruby
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 |
8 | pull_request:
9 |
10 | env:
11 | CI: 1
12 |
13 | jobs:
14 | lint:
15 | runs-on: ubuntu-latest
16 | name: Linter
17 | env:
18 | BUNDLE_JOBS: 4
19 | BUNDLE_RETRY: 3
20 | steps:
21 | - uses: actions/checkout@v3
22 | - name: Set up Ruby
23 | uses: ruby/setup-ruby@v1
24 | with:
25 | ruby-version: "3.3"
26 | bundler-cache: true
27 | - name: Run StandardRB
28 | run: bundle exec standardrb
29 |
30 | build:
31 | runs-on: ubuntu-latest
32 | name: Ruby ${{ matrix.ruby }}
33 | env:
34 | BUNDLE_JOBS: 4
35 | BUNDLE_RETRY: 3
36 | strategy:
37 | matrix:
38 | ruby:
39 | - "3.3"
40 | - "3.2"
41 | - "3.1"
42 | - "3.0"
43 | - "2.7"
44 | - "jruby"
45 | - "truffleruby"
46 | steps:
47 | - uses: actions/checkout@v4
48 | - name: Set up Ruby
49 | uses: ruby/setup-ruby@v1
50 | with:
51 | ruby-version: ${{ matrix.ruby }}
52 | bundler-cache: true
53 | - name: Run RSpec
54 | run: bundle exec rspec
55 | - name: Install RSpec example dependencies
56 | run: bundle install --gemfile ./examples/Gemfile-rspec
57 | - name: Run RSpec example
58 | run: bundle exec --gemfile ./examples/Gemfile-rspec ruby examples/rspec.rb
59 | - name: Install minitest example dependencies
60 | run: bundle install --gemfile ./examples/Gemfile-minitest
61 | - name: Run minitest example
62 | run: bundle exec --gemfile ./examples/Gemfile-minitest ruby examples/minitest.rb
63 | - name: Run RSpec Rails example
64 | working-directory: ./examples/rails_app
65 | run: |
66 | bundle install
67 | bundle exec rspec
68 |
--------------------------------------------------------------------------------
/lib/skooma/dialects/oas_3_1.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Dialects
5 | module OAS31
6 | class << self
7 | def call(registry, **options)
8 | registry.add_source(
9 | "https://spec.openapis.org/oas/3.1/",
10 | JSONSkooma::Sources::Local.new(File.join(DATA_DIR, "oas-3.1").to_s, suffix: ".json")
11 | )
12 |
13 | registry.add_vocabulary(
14 | "https://spec.openapis.org/oas/3.1/vocab/base",
15 | Skooma::Keywords::OAS31::Dialect::AnyOf,
16 | Skooma::Keywords::OAS31::Dialect::OneOf,
17 | Skooma::Keywords::OAS31::Dialect::Discriminator,
18 | Skooma::Keywords::OAS31::Dialect::Xml,
19 | Skooma::Keywords::OAS31::Dialect::ExternalDocs,
20 | Skooma::Keywords::OAS31::Dialect::Example
21 | )
22 |
23 | registry.add_metaschema(
24 | "https://spec.openapis.org/oas/3.1/dialect/base",
25 | "https://json-schema.org/draft/2020-12/vocab/core",
26 | "https://json-schema.org/draft/2020-12/vocab/applicator",
27 | "https://json-schema.org/draft/2020-12/vocab/unevaluated",
28 | "https://json-schema.org/draft/2020-12/vocab/validation",
29 | "https://json-schema.org/draft/2020-12/vocab/format-annotation",
30 | "https://json-schema.org/draft/2020-12/vocab/meta-data",
31 | "https://json-schema.org/draft/2020-12/vocab/content",
32 | "https://spec.openapis.org/oas/3.1/vocab/base"
33 | )
34 |
35 | registry.add_format("int32", Skooma::Validators::Int32)
36 | registry.add_format("int64", Skooma::Validators::Int64)
37 | registry.add_format("float", Skooma::Validators::Float)
38 | registry.add_format("double", Skooma::Validators::Double)
39 | end
40 | end
41 | end
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/lib/skooma/objects/response/keywords/content.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Response
6 | module Keywords
7 | class Content < JSONSkooma::Keywords::Base
8 | self.key = "content"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::MediaType
11 |
12 | def evaluate(instance, result)
13 | return result.discard unless instance["body"].value
14 |
15 | media_type = instance["headers"]&.[]("Content-Type")&.split(";")&.first&.strip&.downcase
16 | media_type_object, matched_media_type = find_media_type(media_type)
17 |
18 | return result.failure("Media type #{media_type} not found") unless media_type_object
19 |
20 | result.annotate(matched_media_type)
21 | result.call(instance, matched_media_type) do |media_type_result|
22 | media_type_object.evaluate(instance["body"], media_type_result)
23 |
24 | result.failure("Invalid content") unless media_type_result.passed?
25 | end
26 | end
27 |
28 | private
29 |
30 | # The key is a media type or media type range and the value describes it.
31 | # For requests that match multiple keys, only the most specific key is applicable.
32 | # e.g. text/plain overrides text/*
33 | def find_media_type(media_type)
34 | matched_media_type =
35 | if json.key?(media_type)
36 | media_type
37 | elsif media_type &&
38 | (key = "#{media_type.split("/").first}/*") &&
39 | json.key?(key)
40 | key
41 | elsif json.key?("*/*")
42 | "*/*"
43 | end
44 |
45 | [json[matched_media_type], matched_media_type]
46 | end
47 | end
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/skooma/objects/operation/keywords/parameters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class Operation
6 | module Keywords
7 | class Parameters < JSONSkooma::Keywords::Base
8 | self.key = "parameters"
9 | self.value_schema = :array_of_schemas
10 | self.schema_value_class = Objects::Parameter
11 |
12 | def initialize(parent_schema, value)
13 | super
14 | keys = json.filter_map { |v| v["in"] && [v["in"].value, v["name"].value] }
15 | parent_params = parent_schema.parent["parameters"] || []
16 | parent_params.reject! do |v|
17 | v["in"] && keys.include?([v["in"].value, v["name"].value])
18 | end
19 | @parent_params = parent_params
20 | end
21 |
22 | def evaluate(instance, result)
23 | return result.discard unless instance.key?("request")
24 | return result.discard if json&.value&.empty? && @parent_params.empty?
25 |
26 | errors = []
27 | process_parameter(json, instance, result) do |subresult|
28 | errors << [subresult.schema_node["in"], subresult.schema_node["name"]] unless subresult.passed?
29 | end
30 | process_parameter(@parent_params, instance, result.parent.sibling(instance, "parameters")) do |subresult|
31 | key = [subresult.schema_node["in"], subresult.schema_node["name"]]
32 | errors << key unless subresult.passed?
33 | end
34 | return if errors.empty?
35 |
36 | result.failure("The following parameters are invalid: #{errors}")
37 | end
38 |
39 | private
40 |
41 | def process_parameter(v, instance, result)
42 | v.each.with_index do |param, index|
43 | result.call(instance["request"], index.to_s) do |subresult|
44 | param.evaluate(instance["request"], subresult)
45 | yield subresult
46 | end
47 | end
48 | end
49 | end
50 | end
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/lib/skooma/objects/openapi/keywords/paths.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | module Objects
5 | class OpenAPI
6 | module Keywords
7 | class Paths < JSONSkooma::Keywords::Base
8 | self.key = "paths"
9 | self.value_schema = :object_of_schemas
10 | self.schema_value_class = Objects::PathItem
11 |
12 | ROUTE_REGEXP = /\{([^}]+)}/.freeze
13 |
14 | def initialize(parent_schema, value)
15 | super
16 | @regexp_map = json.filter_map do |path, subschema|
17 | next unless path.include?("{") && path.include?("}")
18 |
19 | path_regex = path.gsub(ROUTE_REGEXP, "(?<\\1>[^/?#]+)")
20 | path_regex = Regexp.new("\\A#{path_regex}\\z")
21 |
22 | [path, path_regex, subschema]
23 | end
24 | end
25 |
26 | def evaluate(instance, result)
27 | path, attributes, path_schema = find_route(instance["path"])
28 |
29 | return result.failure("Path #{instance["path"]} not found in schema") unless path
30 |
31 | result.annotate({"current_path" => path})
32 |
33 | result.call(instance, path) do |subresult|
34 | subresult.annotate({"path_attributes" => attributes})
35 | path_schema.evaluate(instance, subresult)
36 |
37 | if subresult.passed? && subresult.children.any?
38 | result.success
39 | else
40 | result.failure("Path #{instance["path"]} is invalid")
41 | end
42 | end
43 | end
44 |
45 | private
46 |
47 | def find_route(instance_path)
48 | instance_path = instance_path.delete_prefix(json.root.path_prefix)
49 | return [instance_path, {}, json[instance_path]] if json.key?(instance_path)
50 |
51 | @regexp_map.reduce(nil) do |result, (path, path_regex, subschema)|
52 | next result unless path.include?("{") && path.include?("}")
53 |
54 | match = instance_path.match(path_regex)
55 | next result if match.nil?
56 | next result if result && result[1].length <= match.named_captures.length
57 |
58 | [path, match.named_captures, subschema]
59 | end
60 | end
61 | end
62 | end
63 | end
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/bin/example:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "skooma"
6 |
7 | # Example:
8 |
9 | Skooma.create_registry
10 |
11 | OPENAPI_SCHEMA = {
12 | "openapi": "3.1.0",
13 | "info": { "title": "api", "version": "1.0.0" },
14 | "paths": {
15 | "/": {
16 | "get": {
17 | "responses": {
18 | "200": {
19 | "description": "OK",
20 | "content": {
21 | "application/json": {
22 | "schema": {
23 | "type": "object",
24 | "properties": {
25 | "foo": { "type": "string" },
26 | "bar": { "type": "integer" }
27 | }
28 | }
29 | }
30 | }
31 | }
32 | }
33 | }
34 | },
35 | "/foo": {
36 | "get": {
37 | "responses": {
38 | "200": {
39 | "description": "OK",
40 | "content": {
41 | "application/json": {
42 | "schema": {
43 | "$ref": "#/components/schemas/Foo"
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }
50 | }
51 | },
52 | "components": {
53 | "schemas": {
54 | "Foo": {
55 | "type": "object",
56 | "properties": {
57 | "foo": { "type": "string" },
58 | "bar": { "type": "integer" }
59 | }
60 | }
61 | }
62 | }
63 | }
64 |
65 | REQUEST_SCHEMA = {
66 | method: "get",
67 | path: "/",
68 | query: "?foo=bar",
69 | headers: {
70 | "Content-Type": "application/json"
71 | },
72 | response: {
73 | status: 200,
74 | headers: {
75 | "Content-Type": 'application/json'
76 | },
77 | body: {
78 | foo: 'foo',
79 | bar: 1
80 | }
81 | }
82 | }
83 |
84 | REQUEST_SCHEMA2 = {
85 | method: "get",
86 | path: "/foo",
87 | query: "?foo=bar",
88 | headers: {
89 | "Content-Type": "application/json"
90 | },
91 | response: {
92 | status: 200,
93 | headers: {
94 | "Content-Type": 'application/json'
95 | },
96 | body: {
97 | foo: 'foo',
98 | bar: 1
99 | }
100 | }
101 | }
102 |
103 | OPENAPI = Skooma::OpenAPISchema.new(OPENAPI_SCHEMA)
104 |
105 | # OPENAPI.validate.output(:detailed)
106 | # result = OPENAPI.evaluate(REQUEST_SCHEMA)
107 | # result = OPENAPI.evaluate(REQUEST_SCHEMA2)
108 | # result.output(:detailed)
109 |
110 | require "irb"
111 | IRB.start(__FILE__)
112 |
--------------------------------------------------------------------------------
/data/oas-3.1/meta/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$id": "https://spec.openapis.org/oas/3.1/meta/base",
3 | "$schema": "https://json-schema.org/draft/2020-12/schema",
4 |
5 | "title": "OAS Base vocabulary",
6 | "description": "A JSON Schema Vocabulary used in the OpenAPI Schema Dialect",
7 |
8 | "$vocabulary": {
9 | "https://spec.openapis.org/oas/3.1/vocab/base": true
10 | },
11 |
12 | "$dynamicAnchor": "meta",
13 |
14 | "type": ["object", "boolean"],
15 | "properties": {
16 | "example": true,
17 | "discriminator": { "$ref": "#/$defs/discriminator" },
18 | "externalDocs": { "$ref": "#/$defs/external-docs" },
19 | "xml": { "$ref": "#/$defs/xml" }
20 | },
21 |
22 | "$defs": {
23 | "extensible": {
24 | "patternProperties": {
25 | "^x-": true
26 | }
27 | },
28 |
29 | "discriminator": {
30 | "$ref": "#/$defs/extensible",
31 | "type": "object",
32 | "properties": {
33 | "propertyName": {
34 | "type": "string"
35 | },
36 | "mapping": {
37 | "type": "object",
38 | "additionalProperties": {
39 | "type": "string"
40 | }
41 | }
42 | },
43 | "required": ["propertyName"],
44 | "unevaluatedProperties": false
45 | },
46 |
47 | "external-docs": {
48 | "$ref": "#/$defs/extensible",
49 | "type": "object",
50 | "properties": {
51 | "url": {
52 | "type": "string",
53 | "format": "uri-reference"
54 | },
55 | "description": {
56 | "type": "string"
57 | }
58 | },
59 | "required": ["url"],
60 | "unevaluatedProperties": false
61 | },
62 |
63 | "xml": {
64 | "$ref": "#/$defs/extensible",
65 | "type": "object",
66 | "properties": {
67 | "name": {
68 | "type": "string"
69 | },
70 | "namespace": {
71 | "type": "string",
72 | "format": "uri"
73 | },
74 | "prefix": {
75 | "type": "string"
76 | },
77 | "attribute": {
78 | "type": "boolean"
79 | },
80 | "wrapped": {
81 | "type": "boolean"
82 | }
83 | },
84 | "unevaluatedProperties": false
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/lib/skooma/objects/parameter/keywords/value_parser.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "cgi"
4 |
5 | module Skooma
6 | module Objects
7 | class Parameter
8 | module Keywords
9 | module ValueParser
10 | class << self
11 | def call(instance, result)
12 | type = result.sibling(instance, "in")&.annotation
13 | raise Error, "Missing `in` key #{result.path}" unless type
14 |
15 | key = result.sibling(instance, "name")&.annotation
16 | raise Error, "Missing `name` key #{result.path}" unless key
17 |
18 | case type
19 | when "query"
20 | parse_query(instance)[key]
21 | when "header"
22 | instance["headers"][key]
23 | when "path"
24 | path_item_result = result.parent
25 | path_item_result = path_item_result.parent until path_item_result.key.start_with?("/")
26 |
27 | Instance::Attribute.new(
28 | path_item_result.annotation["path_attributes"][key],
29 | key: "path",
30 | parent: instance["path"]
31 | )
32 | when "cookie"
33 | # instance["headers"]["Cookie"]
34 | else
35 | raise Error, "Unknown location: #{type}"
36 | end
37 | end
38 |
39 | private
40 |
41 | def parse_query(instance)
42 | params = {}
43 | instance["query"]&.value.to_s.split(/[&;]/).each do |pairs|
44 | key, value = pairs.split("=", 2).collect { |v| CGI.unescape(v) }
45 | next unless key
46 |
47 | params[key] = value
48 | end
49 |
50 | Instance::Attribute.new(
51 | params,
52 | key: "query",
53 | parent: instance["query"]
54 | )
55 | end
56 |
57 | def style(value, instance, result)
58 | case result.sibling(instance, "style")
59 | when "simple"
60 | value
61 | when "label"
62 | value.split(".")
63 | when "matrix"
64 | value.split(";")
65 | when "form"
66 | value.split("&")
67 | when "spaceDelimited"
68 | value.split(" ")
69 | when "pipeDelimited"
70 | value.split("|")
71 | when "deepObject"
72 | raise Error, "Not implemented yet"
73 | else
74 | raise Error, "Unknown style: #{result.sibling(instance, "style")}"
75 | end
76 | end
77 | end
78 | end
79 | end
80 | end
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/skooma/matchers/wrapper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "pathname"
4 |
5 | module Skooma
6 | module Matchers
7 | class Wrapper < Module
8 | TEST_REGISTRY_NAME = "skooma_test_registry"
9 |
10 | class << self
11 | alias_method :[], :new
12 | end
13 |
14 | module DefaultHelperMethods
15 | def mapped_response(with_response: true, with_request: true)
16 | Skooma::EnvMapper.call(
17 | request_object.env,
18 | response_object,
19 | with_response: with_response,
20 | with_request: with_request
21 | )
22 | end
23 |
24 | def request_object
25 | # `rails` integration
26 | return @request if defined?(::ActionDispatch) && @request.is_a?(::ActionDispatch::Request)
27 | # `rack-test` integration
28 | return last_request if defined?(::Rack::Test) && defined?(:last_request)
29 |
30 | raise "Request object not found"
31 | end
32 |
33 | def response_object
34 | # `rails` integration
35 | return @response if defined?(::ActionDispatch) && @response.is_a?(::ActionDispatch::Response)
36 | # `rack-test` integration
37 | return last_response if defined?(::Rack::Test) && defined?(:last_response)
38 |
39 | raise "Response object not found"
40 | end
41 |
42 | def skooma_openapi_schema
43 | skooma.schema
44 | end
45 | end
46 |
47 | def initialize(helper_methods_module, openapi_path, base_uri: "https://skoomarb.dev/", path_prefix: "", **params)
48 | super()
49 |
50 | registry = create_test_registry
51 | pathname = Pathname.new(openapi_path)
52 | source_uri = "#{base_uri}#{path_prefix.delete_suffix("/").delete_prefix("/")}"
53 | source_uri += "/" unless source_uri.end_with?("/")
54 | registry.add_source(
55 | source_uri,
56 | JSONSkooma::Sources::Local.new(pathname.dirname.to_s)
57 | )
58 | @schema = registry.schema(URI.parse("#{source_uri}#{pathname.basename}"), schema_class: Skooma::Objects::OpenAPI)
59 | @schema.path_prefix = path_prefix
60 |
61 | @coverage = Coverage.new(@schema, mode: params[:coverage], format: params[:coverage_format])
62 |
63 | include DefaultHelperMethods
64 | include helper_methods_module
65 |
66 | skooma_self = self
67 | define_method :skooma do
68 | skooma_self
69 | end
70 | end
71 |
72 | attr_accessor :schema, :coverage
73 |
74 | private
75 |
76 | def create_test_registry
77 | JSONSkooma::Registry[TEST_REGISTRY_NAME]
78 | rescue JSONSkooma::RegistryError
79 | Skooma.create_registry(name: TEST_REGISTRY_NAME)
80 | end
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/lib/skooma/coverage.rb:
--------------------------------------------------------------------------------
1 | module Skooma
2 | class NoopCoverage
3 | def track_request(*)
4 | end
5 |
6 | def report
7 | end
8 | end
9 |
10 | class Coverage
11 | class SimpleReport
12 | def initialize(coverage)
13 | @coverage = coverage
14 | end
15 |
16 | attr_reader :coverage
17 |
18 | def report
19 | puts <<~MSG
20 | OpenAPI schema #{URI.parse(coverage.schema.uri.to_s).path} coverage report: #{coverage.covered_paths.count} / #{coverage.defined_paths.count} operations (#{coverage.covered_percent.round(2)}%) covered.
21 | #{coverage.uncovered_paths.empty? ? "All paths are covered!" : "Uncovered paths:"}
22 | #{coverage.uncovered_paths.map { |method, path, status| "#{method.upcase} #{path} #{status}" }.join("\n")}
23 | MSG
24 | end
25 | end
26 |
27 | def self.new(schema, mode: nil, format: nil)
28 | case mode
29 | when nil, false
30 | NoopCoverage.new
31 | when :report, :strict
32 | super
33 | else
34 | raise ArgumentError, "Invalid coverage: #{mode}, expected :report, :strict, or false"
35 | end
36 | end
37 |
38 | attr_reader :mode, :format, :defined_paths, :covered_paths, :schema
39 |
40 | def initialize(schema, mode:, format:)
41 | @schema = schema
42 | @mode = mode
43 | @format = format || SimpleReport
44 | @defined_paths = find_defined_paths(schema)
45 | @covered_paths = Set.new
46 | end
47 |
48 | def track_request(result)
49 | operation = [nil, nil, nil]
50 | result.collect_annotations(result.instance, keys: %w[paths responses]) do |node|
51 | case node.key
52 | when "paths"
53 | operation[0] = node.annotation["method"]
54 | operation[1] = node.annotation["current_path"]
55 | when "responses"
56 | operation[2] = node.annotation
57 | end
58 | end
59 | covered_paths << operation
60 | end
61 |
62 | def uncovered_paths
63 | defined_paths - covered_paths
64 | end
65 |
66 | def covered_percent
67 | covered_paths.count * 100.0 / defined_paths.count
68 | end
69 |
70 | def report
71 | format.new(self).report
72 | exit 1 if mode == :strict && uncovered_paths.any?
73 | end
74 |
75 | private
76 |
77 | def find_defined_paths(schema)
78 | Set.new.tap do |paths|
79 | schema["paths"].each do |path, path_item|
80 | resolved_path_item = (path_item.key?("$ref") ? path_item.resolve_ref(path_item["$ref"]) : path_item)
81 | resolved_path_item.slice("get", "post", "put", "patch", "delete", "options", "head", "trace").each do |method, operation|
82 | operation["responses"]&.each do |code, _|
83 | paths << [method, path, code]
84 | end
85 | end
86 | end
87 | end
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/openapi_formats.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "OpenAPI 3.1.0 formats",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "get": {
13 | "responses": {
14 | "200": {
15 | "description": "Success",
16 | "content": {
17 | "application/json": {
18 | "schema": {
19 | "type": "object",
20 | "unevaluatedProperties": false,
21 | "properties": {
22 | "int32": {
23 | "format": "int32"
24 | },
25 | "int64": {
26 | "format": "int64"
27 | },
28 | "float": {
29 | "format": "float"
30 | },
31 | "double": {
32 | "format": "double"
33 | },
34 | "password": {
35 | "format": "password"
36 | }
37 | }
38 | }
39 | }
40 | }
41 | }
42 | }
43 | }
44 | }
45 | }
46 | },
47 | "tests": [
48 | {
49 | "description": "request with valid query attributes",
50 | "data": {
51 | "method": "get",
52 | "path": "/",
53 | "request": {
54 | "headers": {
55 | "Content-Type": "application/json"
56 | }
57 | },
58 | "response": {
59 | "status": 200,
60 | "headers": {
61 | "Content-Type": "application/json"
62 | },
63 | "body": "{\"int32\": 1,\"int64\": 1,\"float\": 1.1,\"double\": 1.1,\"password\": \"password\"}"
64 | }
65 | },
66 | "valid": true
67 | },
68 | {
69 | "description": "request with invalid query attributes ignored when type is string",
70 | "data": {
71 | "method": "get",
72 | "path": "/",
73 | "request": {
74 | "headers": {
75 | "Content-Type": "application/json"
76 | }
77 | },
78 | "response": {
79 | "status": 200,
80 | "headers": {
81 | "Content-Type": "application/json"
82 | },
83 | "body": "{\"int32\": \"string\", \"int64\": \"string\", \"float\": \"string\", \"double\": \"string\", \"password\": \"password\"}"
84 | }
85 | },
86 | "valid": true
87 | },
88 | {
89 | "description": "request with invalid query attributes",
90 | "data": {
91 | "method": "get",
92 | "path": "/",
93 | "request": {
94 | "headers": {
95 | "Content-Type": "application/json"
96 | }
97 | },
98 | "response": {
99 | "status": 200,
100 | "headers": {
101 | "Content-Type": "application/json"
102 | },
103 | "body": "{\"int32\": 1.2, \"int64\": 1.2, \"float\": 123, \"double\": 123, \"password\": \"password\"}"
104 | }
105 | },
106 | "valid": false
107 | }
108 | ]
109 | }
110 | ]
111 |
--------------------------------------------------------------------------------
/lib/skooma/instance.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Skooma
4 | class Instance < JSONSkooma::JSONNode
5 | module Coercible
6 | def coerce(json)
7 | value = self&.value
8 | return self if value.nil?
9 |
10 | case json["type"]
11 | when "integer"
12 | begin
13 | Integer(value, 10)
14 | rescue ArgumentError
15 | value
16 | end
17 | when "number"
18 | begin
19 | Float(value)
20 | rescue ArgumentError
21 | value
22 | end
23 | when "boolean"
24 | return true if value == "true"
25 |
26 | (value == "false") ? false : value
27 | when "object"
28 | value
29 | # convert_object(value, schema)
30 | when "array"
31 | # convert_array(value, schema)
32 | value
33 | else
34 | value
35 | end
36 | end
37 | end
38 |
39 | class Attribute < JSONSkooma::JSONNode
40 | include Coercible
41 |
42 | def initialize(value, **item_params)
43 | super(value, **item_params.merge(item_class: Attribute))
44 | end
45 | end
46 |
47 | class Headers < JSONSkooma::JSONNode
48 | def [](key)
49 | super(key.to_s.downcase)
50 | end
51 |
52 | private
53 |
54 | def map_object_value(value)
55 | value.map { |k, v| [k.to_s.downcase, Attribute.new(v, key: k.to_s, parent: self, **@item_params)] }.to_h
56 | end
57 | end
58 |
59 | class Response < Attribute
60 | private
61 |
62 | def parse_value(value)
63 | data = {}
64 | data["status"] = JSONSkooma::JSONNode.new(value.fetch("status"), key: "status", parent: self)
65 | data["headers"] = Headers.new(value.fetch("headers", {}), key: "headers", parent: self)
66 | body_value = parse_body(value["body"], data["headers"])
67 | data["body"] = Attribute.new(body_value, key: "body", parent: self)
68 | ["object", data]
69 | end
70 |
71 | def parse_body(body, headers)
72 | return nil unless body
73 |
74 | parser = BodyParsers[headers["Content-Type"]&.value&.split(";")&.first]
75 | parser ? parser.call(body, headers: headers) : body
76 | end
77 | end
78 |
79 | class Request < JSONSkooma::JSONNode
80 | private
81 |
82 | def parse_value(value)
83 | data = {}
84 | data["query"] = Attribute.new(value.fetch("query", ""), key: "query", parent: self)
85 | data["headers"] = Headers.new(value.fetch("headers", {}), key: "headers", parent: self)
86 | body_value = parse_body(value["body"], data["headers"])
87 | data["body"] = Attribute.new(body_value, key: "body", parent: self)
88 | ["object", data]
89 | end
90 |
91 | def parse_body(body, headers)
92 | return nil unless body
93 |
94 | parser = BodyParsers[headers["Content-Type"]&.value&.split(";")&.first]
95 | parser ? parser.call(body, headers: headers) : body
96 | end
97 | end
98 |
99 | private
100 |
101 | def parse_value(value)
102 | data = {
103 | "method" => JSONSkooma::JSONNode.new(value["method"], key: "method", parent: self),
104 | "path" => JSONSkooma::JSONNode.new(value["path"], key: "path", parent: self)
105 | }
106 | data["request"] = Request.new(value["request"], key: "request", parent: self) if value["request"]
107 | data["response"] = Response.new(value["response"], key: "response", parent: self) if value["response"]
108 |
109 | ["object", data]
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/response_headers.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Response headers validation",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "get": {
13 | "responses": {
14 | "200": {
15 | "description": "Success",
16 | "headers": {
17 | "X-Expires-At": {
18 | "required": true,
19 | "schema": {
20 | "type": "string",
21 | "format": "date-time"
22 | }
23 | },
24 | "X-Rate-Limit": {
25 | "description": "calls per hour allowed by the user",
26 | "content": {
27 | "application/json": {
28 | "schema": {
29 | "type": "string"
30 | }
31 | }
32 | }
33 | },
34 | "Server": {
35 | "schema": {
36 | "type": "string"
37 | }
38 | }
39 | },
40 | "content": {
41 | "application/json": {
42 | "schema": {}
43 | }
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }
50 | },
51 | "tests": [
52 | {
53 | "description": "request with valid response headers",
54 | "data": {
55 | "method": "get",
56 | "path": "/",
57 | "request": {
58 | "headers": {
59 | "Content-Type": "application/json"
60 | }
61 | },
62 | "response": {
63 | "status": 200,
64 | "headers": {
65 | "Content-Type": "application/json",
66 | "X-Expires-At": "2020-01-01T00:00:00Z",
67 | "X-Rate-Limit": "100",
68 | "SERVER": "nginx"
69 | },
70 | "body": "{\"foo\": \"foo\", \"bar\": 1}"
71 | }
72 | },
73 | "valid": true
74 | },
75 | {
76 | "description": "request without optional response headers",
77 | "data": {
78 | "method": "get",
79 | "path": "/",
80 | "request": {
81 | "headers": {
82 | "Content-Type": "application/json"
83 | }
84 | },
85 | "response": {
86 | "status": 200,
87 | "headers": {
88 | "Content-Type": "application/json",
89 | "X-Expires-At": "2020-01-01T00:00:00Z"
90 | },
91 | "body": "{\"foo\": \"foo\", \"bar\": 1}"
92 | }
93 | },
94 | "valid": true
95 | },
96 | {
97 | "description": "request with invalid response headers",
98 | "data": {
99 | "method": "get",
100 | "path": "/",
101 | "request": {
102 | "headers": {
103 | "Content-Type": "application/json"
104 | }
105 | },
106 | "response": {
107 | "status": 200,
108 | "headers": {
109 | "Content-Type": "application/json",
110 | "X-Expires-At": "Christmas",
111 | "Server": "nginx"
112 | },
113 | "body": "{\"foo\": \"foo\", \"bar\": 1}"
114 | }
115 | },
116 | "valid": false
117 | },
118 | {
119 | "description": "request with missing required response headers",
120 | "data": {
121 | "method": "get",
122 | "path": "/",
123 | "request": {
124 | "headers": {
125 | "Content-Type": "application/json"
126 | }
127 | },
128 | "response": {
129 | "status": 200,
130 | "headers": {
131 | "Content-Type": "application/json",
132 | "Server": "nginx"
133 | },
134 | "body": "{\"foo\": \"foo\", \"bar\": 1}"
135 | }
136 | },
137 | "valid": false
138 | }
139 | ]
140 | }
141 | ]
142 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/query_params.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Response query params validation",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "get": {
13 | "parameters": [
14 | {
15 | "in": "query",
16 | "required": true,
17 | "name": "foo",
18 | "schema": {
19 | "type": "string",
20 | "minLength": 3
21 | }
22 | },
23 | {
24 | "in": "query",
25 | "required": false,
26 | "content": {
27 | "application/json": {
28 | "schema": {
29 | "type": "string",
30 | "minLength": 3
31 | }
32 | }
33 | },
34 | "name": "bar"
35 | }
36 | ],
37 | "responses": {
38 | "200": {
39 | "description": "Success",
40 | "content": {
41 | "application/json": {
42 | "schema": {}
43 | }
44 | }
45 | }
46 | }
47 | }
48 | }
49 | }
50 | },
51 | "tests": [
52 | {
53 | "description": "request with valid query attributes",
54 | "data": {
55 | "method": "get",
56 | "path": "/",
57 | "request": {
58 | "query": "foo=foo&bar=bar",
59 | "headers": {
60 | "Content-Type": "application/json"
61 | }
62 | },
63 | "response": {
64 | "status": 200,
65 | "headers": {
66 | "Content-Type": "application/json"
67 | },
68 | "body": null
69 | }
70 | },
71 | "valid": true
72 | },
73 | {
74 | "description": "request without required query attribute",
75 | "data": {
76 | "method": "get",
77 | "path": "/",
78 | "request": {
79 | "query": "bar=bar",
80 | "headers": {
81 | "Content-Type": "application/json"
82 | }
83 | },
84 | "response": {
85 | "status": 200,
86 | "headers": {
87 | "Content-Type": "application/json"
88 | },
89 | "body": null
90 | }
91 | },
92 | "valid": false
93 | },
94 | {
95 | "description": "request without optional query attribute",
96 | "data": {
97 | "method": "get",
98 | "path": "/",
99 | "request": {
100 | "query": "foo=foo",
101 | "headers": {
102 | "Content-Type": "application/json"
103 | }
104 | },
105 | "response": {
106 | "status": 200,
107 | "headers": {
108 | "Content-Type": "application/json"
109 | },
110 | "body": null
111 | }
112 | },
113 | "valid": true
114 | },
115 | {
116 | "description": "request with invalid query attribute",
117 | "data": {
118 | "method": "get",
119 | "path": "/",
120 | "request": {
121 | "query": "foo=f&bar=bar",
122 | "headers": {
123 | "Content-Type": "application/json"
124 | }
125 | },
126 | "response": {
127 | "status": 200,
128 | "headers": {
129 | "Content-Type": "application/json"
130 | },
131 | "body": null
132 | }
133 | },
134 | "valid": false
135 | },
136 | {
137 | "description": "request with invalid query attribute",
138 | "data": {
139 | "method": "get",
140 | "path": "/",
141 | "request": {
142 | "query": "foo=foo&bar=b",
143 | "headers": {
144 | "Content-Type": "application/json"
145 | }
146 | },
147 | "response": {
148 | "status": 200,
149 | "headers": {
150 | "Content-Type": "application/json"
151 | },
152 | "body": null
153 | }
154 | },
155 | "valid": false
156 | }
157 | ]
158 | }
159 | ]
160 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog],
6 | and this project adheres to [Semantic Versioning].
7 |
8 | ## [Unreleased]
9 |
10 | ## [0.3.3] - 2024-10-14
11 |
12 | ### Fixed
13 |
14 | - Fix coverage for Minitest. ([@skryukov])
15 |
16 | ## [0.3.2] - 2024-06-24
17 |
18 | ### Fixed
19 |
20 | - Fix deprecation `MiniTest::Unit.after_tests is now Minitest.after_run`. ([@barnaclebarnes])
21 | - Exclude test helpers from eager loading. ([@skryukov])
22 | - Update oas-3.1 base schema. ([@skryukov])
23 |
24 | ## [0.3.1] - 2024-04-11
25 |
26 | ### Added
27 |
28 | - Add coverage for tested API operations. ([@skryukov])
29 |
30 | ```ruby
31 |
32 | # spec/rails_helper.rb
33 |
34 | RSpec.configure do |config|
35 | # To enable coverage, pass `coverage: :report` option,
36 | # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
37 | config.include Skooma::RSpec[Rails.root.join("docs", "openapi.yml"), coverage: :report], type: :request
38 | end
39 | ```
40 |
41 | ```shell
42 | $ bundle exec rspec
43 | # ...
44 | OpenAPI schema /openapi.yml coverage report: 110 / 194 operations (56.7%) covered.
45 | Uncovered paths:
46 | GET /api/uncovered 200
47 | GET /api/partially_covered 403
48 | # ...
49 | ```
50 |
51 | ## [0.3.0] - 2024-04-09
52 |
53 | ### Changed
54 |
55 | - BREAKING CHANGE: Pass `headers` parameter to registered `BodyParsers`. ([@skryukov])
56 |
57 | ```ruby
58 | # Before:
59 | Skooma::BodyParsers.register("application/xml", ->(body) { Hash.from_xml(body) })
60 | # After:
61 | Skooma::BodyParsers.register("application/xml", ->(body, headers:) { Hash.from_xml(body) })
62 | ```
63 | ### Fixed
64 |
65 | - Fix wrong path when combined with Rails exceptions_app. ([@ursm])
66 |
67 | ## [0.2.3] - 2024-01-18
68 |
69 | ### Added
70 |
71 | - Add support for multiple OpenAPI documents. ([@skryukov])
72 |
73 | ### Fixed
74 |
75 | - Fix `Skooma::Error: Missing name key /request` by setting `content` and `required` keyword dependencies. ([@skryukov])
76 |
77 | ## [0.2.2] - 2024-01-04
78 |
79 | ### Added
80 |
81 | - Add support for APIs mounted under a path prefix. ([@skryukov])
82 |
83 | ```ruby
84 | # spec/rails_helper.rb
85 |
86 | RSpec.configure do |config|
87 | # ...
88 | path_to_openapi = Rails.root.join("docs", "openapi.yml")
89 | # pass path_prefix option if your API is mounted under a prefix:
90 | config.include Skooma::RSpec[path_to_openapi, path_prefix: "/internal/api"], type: :request
91 | end
92 | ```
93 |
94 | ### Changed
95 |
96 | - Bump `json_skooma` version to `~> 0.2.0`. ([@skryukov])
97 |
98 | ### Fixed
99 |
100 | - Better checks to automatic request/response detection to prevent methods overrides via RSpec helpers (i.e. `subject(:response)`). ([@skryukov])
101 | - Fail response validation when expected response code or `responses` keyword aren't listed. ([@skryukov])
102 |
103 | ## [0.2.1] - 2023-10-23
104 |
105 | ### Fixed
106 |
107 | - Raise error when parameter attributes misses required keys. ([@skryukov])
108 | - Fix output format. ([@skryukov])
109 |
110 | ## [0.2.0] - 2023-10-23
111 |
112 | ### Added
113 |
114 | - Add `minitest` and `rake-test` support. ([@skryukov])
115 | - Add `discriminator` keyword support. ([@skryukov])
116 |
117 | ### Fixed
118 |
119 | - Fix Zeitwerk eager loading. ([@skryukov])
120 |
121 | ## [0.1.0] - 2023-09-27
122 |
123 | ### Added
124 |
125 | - Initial implementation. ([@skryukov])
126 |
127 | [@barnaclebarnes]: https://github.com/barnaclebarnes
128 | [@skryukov]: https://github.com/skryukov
129 | [@ursm]: https://github.com/ursm
130 |
131 | [Unreleased]: https://github.com/skryukov/skooma/compare/v0.3.3...HEAD
132 | [0.3.3]: https://github.com/skryukov/skooma/compare/v0.3.2...v0.3.3
133 | [0.3.2]: https://github.com/skryukov/skooma/compare/v0.3.1...v0.3.2
134 | [0.3.1]: https://github.com/skryukov/skooma/compare/v0.3.0...v0.3.1
135 | [0.3.0]: https://github.com/skryukov/skooma/compare/v0.2.3...v0.3.0
136 | [0.2.3]: https://github.com/skryukov/skooma/compare/v0.2.2...v0.2.3
137 | [0.2.2]: https://github.com/skryukov/skooma/compare/v0.2.1...v0.2.2
138 | [0.2.1]: https://github.com/skryukov/skooma/compare/v0.2.0...v0.2.1
139 | [0.2.0]: https://github.com/skryukov/skooma/compare/v0.1.0...v0.2.0
140 | [0.1.0]: https://github.com/skryukov/skooma/commits/v0.1.0
141 |
142 | [Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
143 | [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
144 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/meta_type_matches.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Meta type matches",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "post": {
13 | "requestBody": {
14 | "content": {
15 | "application/json": {
16 | "schema": {
17 | "type": "object",
18 | "unevaluatedProperties": false,
19 | "properties": {
20 | "foo": {
21 | "type": "string"
22 | },
23 | "bar": {
24 | "type": "integer"
25 | }
26 | }
27 | }
28 | }
29 | }
30 | },
31 | "responses": {
32 | "200": {
33 | "description": "Success",
34 | "content": {
35 | "text/plain": {
36 | "schema": {
37 | "type": "string"
38 | }
39 | },
40 | "application/*": {
41 | "schema": {
42 | "type": "string"
43 | }
44 | },
45 | "*/*": {
46 | "schema": {}
47 | },
48 | "application/json": {
49 | "schema": {
50 | "type": "object",
51 | "unevaluatedProperties": false,
52 | "properties": {
53 | "foo": {
54 | "type": "string"
55 | }
56 | }
57 | }
58 | }
59 | }
60 | }
61 | }
62 | }
63 | }
64 | }
65 | },
66 | "tests": [
67 | {
68 | "description": "unknown request body media type",
69 | "data": {
70 | "method": "post",
71 | "path": "/",
72 | "request": {
73 | "headers": {
74 | "Content-Type": "multipart/form-data"
75 | },
76 | "body": "foo=bar"
77 | },
78 | "response": {
79 | "status": 200,
80 | "headers": {
81 | "Content-Type": "text/html"
82 | },
83 | "body": ""
84 | }
85 | },
86 | "valid": false
87 | },
88 | {
89 | "description": "known request body media type",
90 | "data": {
91 | "method": "post",
92 | "path": "/",
93 | "request": {
94 | "headers": {
95 | "Content-Type": "application/json"
96 | }
97 | },
98 | "response": {
99 | "status": 200,
100 | "headers": {
101 | "Content-Type": "application/json"
102 | },
103 | "body": "{\"foo\": \"bar\"}"
104 | }
105 | },
106 | "valid": true
107 | },
108 | {
109 | "description": "partial match",
110 | "data": {
111 | "method": "post",
112 | "path": "/",
113 | "request": {
114 | "headers": {
115 | "Content-Type": "application/json"
116 | }
117 | },
118 | "response": {
119 | "status": 200,
120 | "headers": {
121 | "Content-Type": "application/octet-stream"
122 | },
123 | "body": ""
124 | }
125 | },
126 | "valid": true
127 | },
128 | {
129 | "description": "partial match with invalid body",
130 | "data": {
131 | "method": "post",
132 | "path": "/",
133 | "request": {
134 | "headers": {
135 | "Content-Type": "application/json"
136 | }
137 | },
138 | "response": {
139 | "status": 200,
140 | "headers": {
141 | "Content-Type": "application/octet-stream"
142 | },
143 | "body": 42
144 | }
145 | },
146 | "valid": false
147 | },
148 | {
149 | "description": "fallback match",
150 | "data": {
151 | "method": "post",
152 | "path": "/",
153 | "request": {
154 | "headers": {
155 | "Content-Type": "application/json"
156 | }
157 | },
158 | "response": {
159 | "status": 200,
160 | "headers": {
161 | "Content-Type": "image/png"
162 | },
163 | "body": 42
164 | }
165 | },
166 | "valid": true
167 | }
168 | ]
169 | }
170 | ]
171 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/discriminator.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Discriminator",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "get": {
13 | "responses": {
14 | "200": {
15 | "description": "Success",
16 | "content": {
17 | "application/json": {
18 | "schema": {
19 | "oneOf": [
20 | {
21 | "$ref": "#/components/schemas/Cat"
22 | },
23 | {
24 | "$ref": "#/components/schemas/Doggy"
25 | },
26 | {
27 | "$ref": "#/components/schemas/CuteHamster"
28 | }
29 | ],
30 | "discriminator": {
31 | "propertyName": "kind",
32 | "mapping": {
33 | "Dog": "#/components/schemas/Doggy",
34 | "Hamster": "CuteHamster"
35 | }
36 | }
37 | }
38 | }
39 | }
40 | }
41 | }
42 | }
43 | }
44 | },
45 | "components": {
46 | "schemas": {
47 | "Pet": {
48 | "type": "object",
49 | "required": [
50 | "kind"
51 | ],
52 | "properties": {
53 | "kind": {
54 | "type": "string"
55 | }
56 | }
57 | },
58 | "Cat": {
59 | "unevaluatedProperties": false,
60 | "allOf": [
61 | {
62 | "$ref": "#/components/schemas/Pet"
63 | },
64 | {
65 | "type": "object",
66 | "properties": {
67 | "color": {
68 | "type": "string"
69 | }
70 | }
71 | }
72 | ]
73 | },
74 | "Doggy": {
75 | "unevaluatedProperties": false,
76 | "allOf": [
77 | {
78 | "$ref": "#/components/schemas/Pet"
79 | },
80 | {
81 | "type": "object",
82 | "properties": {
83 | "breed": {
84 | "type": "string"
85 | }
86 | }
87 | }
88 | ]
89 | },
90 | "CuteHamster": {
91 | "unevaluatedProperties": false,
92 | "allOf": [
93 | {
94 | "$ref": "#/components/schemas/Pet"
95 | },
96 | {
97 | "type": "object",
98 | "properties": {
99 | "cuteness": {
100 | "type": "integer",
101 | "format": "int32"
102 | }
103 | }
104 | }
105 | ]
106 | }
107 | }
108 | }
109 | },
110 | "tests": [
111 | {
112 | "description": "response with valid discriminator",
113 | "data": {
114 | "method": "get",
115 | "path": "/",
116 | "request": {
117 | "headers": {
118 | "Content-Type": "application/json"
119 | }
120 | },
121 | "response": {
122 | "status": 200,
123 | "headers": {
124 | "Content-Type": "application/json"
125 | },
126 | "body": "{\"color\": \"black\", \"kind\": \"Cat\"}"
127 | }
128 | },
129 | "valid": true
130 | },
131 | {
132 | "description": "response with valid mapped discriminator",
133 | "data": {
134 | "method": "get",
135 | "path": "/",
136 | "request": {
137 | "headers": {
138 | "Content-Type": "application/json"
139 | }
140 | },
141 | "response": {
142 | "status": 200,
143 | "headers": {
144 | "Content-Type": "application/json"
145 | },
146 | "body": "{\"breed\": \"pug\", \"kind\": \"Dog\"}"
147 | }
148 | },
149 | "valid": true
150 | },
151 | {
152 | "description": "response with valid mapped name-only discriminator",
153 | "data": {
154 | "method": "get",
155 | "path": "/",
156 | "request": {
157 | "headers": {
158 | "Content-Type": "application/json"
159 | }
160 | },
161 | "response": {
162 | "status": 200,
163 | "headers": {
164 | "Content-Type": "application/json"
165 | },
166 | "body": "{\"cuteness\": 9000, \"kind\": \"Hamster\"}"
167 | }
168 | },
169 | "valid": true
170 | },
171 | {
172 | "description": "response with unknown discriminator",
173 | "data": {
174 | "method": "get",
175 | "path": "/",
176 | "request": {
177 | "headers": {
178 | "Content-Type": "application/json"
179 | }
180 | },
181 | "response": {
182 | "status": 200,
183 | "headers": {
184 | "Content-Type": "application/json"
185 | },
186 | "body": "{\"kind\": \"Unicorn\"}"
187 | }
188 | },
189 | "valid": false
190 | },
191 | {
192 | "description": "response without discriminator value",
193 | "data": {
194 | "method": "get",
195 | "path": "/",
196 | "request": {
197 | "headers": {
198 | "Content-Type": "application/json"
199 | }
200 | },
201 | "response": {
202 | "status": 200,
203 | "headers": {
204 | "Content-Type": "application/json"
205 | },
206 | "body": "{\"foo\": \"bar\"}"
207 | }
208 | },
209 | "valid": false
210 | }
211 | ]
212 | }
213 | ]
214 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Skooma – Sugar for your APIs
2 |
3 | [](https://rubygems.org/gems/skooma)
4 | [](https://github.com/skryukov/skooma/actions/workflows/main.yml)
5 |
6 |
7 |
8 | Skooma is a Ruby library for validating API implementations against OpenAPI documents.
9 |
10 | ### Features
11 |
12 | - Supports OpenAPI 3.1.0
13 | - Supports OpenAPI document validation
14 | - Supports request/response validations against OpenAPI document
15 | - Includes RSpec and Minitest helpers
16 |
17 | ### Learn more
18 |
19 | - [Let there be docs! A documentation-first approach to Rails API development](https://evilmartians.com/chronicles/let-there-be-docs-a-documentation-first-approach-to-rails-api-development)
20 |
21 |
22 |
23 |
24 |
25 | ## Installation
26 |
27 | Install the gem and add to the application's Gemfile by executing:
28 |
29 | $ bundle add skooma
30 |
31 | If bundler is not being used to manage dependencies, install the gem by executing:
32 |
33 | $ gem install skooma
34 |
35 | ## Usage
36 |
37 | Skooma provides `rspec` and `minitest` helpers for validating OpenAPI documents and requests/responses against them.
38 | Skooma helpers are designed to be used with `rails` request specs or `rack-test`.
39 |
40 | ### RSpec
41 |
42 | #### Configuration
43 |
44 | ```ruby
45 | # spec/rails_helper.rb
46 |
47 | RSpec.configure do |config|
48 | # ...
49 | path_to_openapi = Rails.root.join("docs", "openapi.yml")
50 | config.include Skooma::RSpec[path_to_openapi], type: :request
51 |
52 | # OR pass path_prefix option if your API is mounted under a prefix:
53 | config.include Skooma::RSpec[path_to_openapi, path_prefix: "/internal/api"], type: :request
54 |
55 | # To enable coverage, pass `coverage: :report` option,
56 | # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
57 | config.include Skooma::RSpec[path_to_openapi, coverage: :report], type: :request
58 | end
59 | ```
60 |
61 | #### Validate OpenAPI document
62 |
63 | ```ruby
64 | # spec/openapi_spec.rb
65 |
66 | require "rails_helper"
67 |
68 | describe "OpenAPI document", type: :request do
69 | subject(:schema) { skooma_openapi_schema }
70 |
71 | it { is_expected.to be_valid_document }
72 | end
73 | ```
74 |
75 | #### Validate request
76 |
77 | ```ruby
78 | # spec/requests/feed_spec.rb
79 |
80 | require "rails_helper"
81 |
82 | describe "/animals/:animal_id/feed" do
83 | let(:animal) { create(:animal, :unicorn) }
84 |
85 | describe "POST" do
86 | subject { post "/animals/#{animal.id}/feed", body:, as: :json }
87 |
88 | let(:body) { {food: "apple", quantity: 3} }
89 |
90 | it { is_expected.to conform_schema(200) }
91 |
92 | context "with wrong food type" do
93 | let(:body) { {food: "wood", quantity: 1} }
94 |
95 | it { is_expected.to conform_schema(422) }
96 | end
97 | end
98 | end
99 |
100 | # Validation Result:
101 | #
102 | # {"valid"=>false,
103 | # "instanceLocation"=>"",
104 | # "keywordLocation"=>"",
105 | # "absoluteKeywordLocation"=>"urn:uuid:1b4b39eb-9b93-4cc1-b6ac-32a25d9bff50#",
106 | # "errors"=>
107 | # [{"instanceLocation"=>"",
108 | # "keywordLocation"=>
109 | # "/paths/~1animals~1{animalId}~1feed/post/responses/200"/
110 | # "/content/application~1json/schema/required",
111 | # "error"=>
112 | # "The object is missing required properties"/
113 | # " [\"animalId\", \"food\", \"amount\"]"}]}
114 | ```
115 |
116 | ### Minitest
117 |
118 | #### Configuration
119 |
120 | ```ruby
121 | # test/test_helper.rb
122 | path_to_openapi = Rails.root.join("docs", "openapi.yml")
123 | ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi]
124 |
125 | # OR pass path_prefix option if your API is mounted under a prefix:
126 | ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, path_prefix: "/internal/api"], type: :request
127 |
128 | # To enable coverage, pass `coverage: :report` option,
129 | # and to raise an error when an operation is not covered, pass `coverage: :strict` option:
130 | ActionDispatch::IntegrationTest.include Skooma::Minitest[path_to_openapi, coverage: :report], type: :request
131 | ```
132 |
133 | #### Validate OpenAPI document
134 |
135 | ```ruby
136 | # test/openapi_test.rb
137 |
138 | require "test_helper"
139 |
140 | class OpenapiTest < ActionDispatch::IntegrationTest
141 | test "is valid OpenAPI document" do
142 | assert_is_valid_document(skooma_openapi_schema)
143 | end
144 | end
145 | ```
146 |
147 | #### Validate request
148 |
149 | ```ruby
150 | # test/integration/items_test.rb
151 |
152 | require "test_helper"
153 |
154 | class ItemsTest < ActionDispatch::IntegrationTest
155 | test "GET /" do
156 | get "/"
157 | assert_conform_schema(200)
158 | end
159 |
160 | test "POST / conforms to schema with 201 response code" do
161 | post "/", params: {foo: "bar"}, as: :json
162 | assert_conform_schema(201)
163 | end
164 |
165 | test "POST / conforms to schema with 400 response code" do
166 | post "/", params: {foo: "baz"}, as: :json
167 | assert_conform_response_schema(400)
168 | end
169 | end
170 | ```
171 |
172 | ## Alternatives
173 |
174 | - [openapi_first](https://github.com/ahx/openapi_first)
175 | - [committee](https://github.com/interagent/committee)
176 |
177 | ## Feature plans
178 |
179 | - Full support for external `$ref`s
180 | - Full OpenAPI 3.1.0 support:
181 | - respect `style` and `explode` keywords
182 | - xml
183 | - Callbacks and webhooks validations
184 | - Example validations
185 | - Ability to plug in custom X-*** keyword classes
186 |
187 | ## Development
188 |
189 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
190 |
191 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
192 |
193 | ## Contributing
194 |
195 | Bug reports and pull requests are welcome on GitHub at https://github.com/skryukov/skooma.
196 |
197 | ## License
198 |
199 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
200 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/template_routes.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Template routes",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/users/{id}": {
12 | "parameters": [
13 | {
14 | "name": "id",
15 | "in": "path",
16 | "required": true,
17 | "schema": {
18 | "type": "string",
19 | "const": "skip"
20 | }
21 | }
22 | ],
23 | "get": {
24 | "parameters": [
25 | {
26 | "name": "id",
27 | "in": "path",
28 | "required": true,
29 | "schema": {
30 | "type": "string",
31 | "pattern": "^[0-9]+$"
32 | }
33 | }
34 | ],
35 | "responses": {
36 | "201": {
37 | "$ref": "#/components/responses/Foo"
38 | }
39 | }
40 | }
41 | },
42 | "/users/me": {
43 | "get": {
44 | "responses": {
45 | "200": {
46 | "$ref": "#/components/responses/Foo"
47 | }
48 | }
49 | }
50 | },
51 | "/users/{id}/{item}/{itemId}": {
52 | "get": {
53 | "parameters": [
54 | {
55 | "name": "id",
56 | "in": "path",
57 | "required": true,
58 | "schema": {
59 | "type": "string",
60 | "pattern": "^[0-9]+$"
61 | }
62 | },
63 | {
64 | "name": "item",
65 | "in": "path",
66 | "required": true,
67 | "schema": {
68 | "type": "string"
69 | }
70 | },
71 | {
72 | "name": "itemId",
73 | "in": "path",
74 | "required": true,
75 | "schema": {
76 | "type": "string",
77 | "pattern": "^[0-9]+$"
78 | }
79 | }
80 | ],
81 | "responses": {
82 | "203": {
83 | "$ref": "#/components/responses/Foo"
84 | }
85 | }
86 | }
87 | },
88 | "/users/{id}/comments/{commentId}": {
89 | "parameters": [
90 | {
91 | "name": "id",
92 | "in": "path",
93 | "required": true,
94 | "schema": {
95 | "type": "string",
96 | "pattern": "^[0-9]+$"
97 | }
98 | },
99 | {
100 | "name": "commentId",
101 | "in": "path",
102 | "required": true,
103 | "schema": {
104 | "type": "string",
105 | "pattern": "^[0-9]+$"
106 | }
107 | }
108 | ],
109 | "get": {
110 | "responses": {
111 | "202": {
112 | "$ref": "#/components/responses/Foo"
113 | }
114 | }
115 | }
116 | }
117 | },
118 | "components": {
119 | "responses": {
120 | "Foo": {
121 | "description": "Success",
122 | "content": {
123 | "application/json": {
124 | "schema": {
125 | "type": "object",
126 | "unevaluatedProperties": false,
127 | "properties": {
128 | "foo": {
129 | "type": "string"
130 | }
131 | }
132 | }
133 | }
134 | }
135 | }
136 | }
137 | }
138 | },
139 | "tests": [
140 | {
141 | "description": "simple route",
142 | "data": {
143 | "method": "get",
144 | "path": "/users/me",
145 | "request": {
146 | "headers": {
147 | "Content-Type": "application/json"
148 | }
149 | },
150 | "response": {
151 | "status": 200,
152 | "headers": {
153 | "Content-Type": "application/json"
154 | },
155 | "body": "{\"foo\": \"bar\"}"
156 | }
157 | },
158 | "valid": true
159 | },
160 | {
161 | "description": "template route",
162 | "data": {
163 | "method": "get",
164 | "path": "/users/42",
165 | "request": {
166 | "headers": {
167 | "Content-Type": "application/json"
168 | }
169 | },
170 | "response": {
171 | "status": 201,
172 | "headers": {
173 | "Content-Type": "application/json"
174 | },
175 | "body": "{\"foo\": \"bar\"}"
176 | }
177 | },
178 | "valid": true
179 | },
180 | {
181 | "description": "invalid parameter path",
182 | "data": {
183 | "method": "get",
184 | "path": "/users/err",
185 | "request": {
186 | "headers": {
187 | "Content-Type": "application/json"
188 | }
189 | },
190 | "response": {
191 | "status": 201,
192 | "headers": {
193 | "Content-Type": "application/json"
194 | },
195 | "body": "{\"foo\": \"bar\"}"
196 | }
197 | },
198 | "valid": false
199 | },
200 | {
201 | "description": "template route with multiple params",
202 | "data": {
203 | "method": "get",
204 | "path": "/users/42/comments/255",
205 | "request": {
206 | "headers": {
207 | "Content-Type": "application/json"
208 | }
209 | },
210 | "response": {
211 | "status": 202,
212 | "headers": {
213 | "Content-Type": "application/json"
214 | },
215 | "body": "{\"foo\": \"bar\"}"
216 | }
217 | },
218 | "valid": true
219 | },
220 | {
221 | "description": "template route with even more template params",
222 | "data": {
223 | "method": "get",
224 | "path": "/users/42/components/36",
225 | "request": {
226 | "headers": {
227 | "Content-Type": "application/json"
228 | }
229 | },
230 | "response": {
231 | "status": 203,
232 | "headers": {
233 | "Content-Type": "application/json"
234 | },
235 | "body": "{\"foo\": \"bar\"}"
236 | }
237 | },
238 | "valid": true
239 | }
240 | ]
241 | }
242 | ]
243 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/header_params.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Request headers validation",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/": {
12 | "get": {
13 | "parameters": [
14 | {
15 | "$ref": "#/components/parameters/X-Expires-At"
16 | },
17 | {
18 | "in": "header",
19 | "name": "X-Rate-Limit",
20 | "description": "calls per hour allowed by the user",
21 | "content": {
22 | "application/json": {
23 | "schema": {
24 | "type": "string",
25 | "minLength": 3
26 | }
27 | }
28 | }
29 | }
30 | ],
31 | "responses": {
32 | "200": {
33 | "description": "Success",
34 | "content": {
35 | "application/json": {
36 | "schema": {}
37 | }
38 | }
39 | }
40 | }
41 | }
42 | },
43 | "/parent": {
44 | "parameters": [
45 | {
46 | "$ref": "#/components/parameters/X-Expires-At"
47 | },
48 | {
49 | "in": "header",
50 | "name": "X-Rate-Limit",
51 | "description": "calls per hour allowed by the user",
52 | "content": {
53 | "application/json": {
54 | "schema": {
55 | "type": "string",
56 | "minLength": 3
57 | }
58 | }
59 | }
60 | }
61 | ],
62 | "get": {
63 | "parameters": [
64 | {
65 | "in": "header",
66 | "name": "X-Rate-Limit",
67 | "description": "calls per hour allowed by the user",
68 | "schema": {
69 | "type": "string",
70 | "minLength": 2
71 | }
72 | }
73 | ],
74 | "responses": {
75 | "200": {
76 | "description": "Success",
77 | "content": {
78 | "application/json": {
79 | "schema": {}
80 | }
81 | }
82 | }
83 | }
84 | }
85 | }
86 | },
87 | "components": {
88 | "parameters": {
89 | "X-Expires-At": {
90 | "in": "header",
91 | "name": "X-Expires-At",
92 | "required": true,
93 | "schema": {
94 | "type": "string",
95 | "format": "date-time"
96 | }
97 | }
98 | }
99 | }
100 | },
101 | "tests": [
102 | {
103 | "description": "request with valid headers",
104 | "data": {
105 | "method": "get",
106 | "path": "/",
107 | "request": {
108 | "headers": {
109 | "Content-Type": "application/json",
110 | "X-Expires-At": "2020-01-01T00:00:00Z",
111 | "X-Rate-Limit": "100"
112 | }
113 | },
114 | "response": {
115 | "status": 200,
116 | "headers": {
117 | "Content-Type": "application/json"
118 | },
119 | "body": "{}"
120 | }
121 | },
122 | "valid": true
123 | },
124 | {
125 | "description": "request without required header",
126 | "data": {
127 | "method": "get",
128 | "path": "/",
129 | "request": {
130 | "headers": {
131 | "Content-Type": "application/json",
132 | "X-Rate-Limit": "100"
133 | }
134 | },
135 | "response": {
136 | "status": 200,
137 | "headers": {
138 | "Content-Type": "application/json"
139 | },
140 | "body": "{}"
141 | }
142 | },
143 | "valid": false
144 | },
145 | {
146 | "description": "request without optional header",
147 | "data": {
148 | "method": "get",
149 | "path": "/",
150 | "request": {
151 | "headers": {
152 | "Content-Type": "application/json",
153 | "X-Expires-At": "2020-01-01T00:00:00Z"
154 | }
155 | },
156 | "response": {
157 | "status": 200,
158 | "headers": {
159 | "Content-Type": "application/json"
160 | },
161 | "body": "{}"
162 | }
163 | },
164 | "valid": true
165 | },
166 | {
167 | "description": "request with invalid header",
168 | "data": {
169 | "method": "get",
170 | "path": "/",
171 | "request": {
172 | "headers": {
173 | "Content-Type": "application/json",
174 | "X-Expires-At": "2020-01-01T00:00:00Z",
175 | "X-Rate-Limit": "1"
176 | }
177 | },
178 | "response": {
179 | "status": 200,
180 | "headers": {
181 | "Content-Type": "application/json"
182 | },
183 | "body": "{}"
184 | }
185 | },
186 | "valid": false
187 | },
188 | {
189 | "description": "request with invalid header",
190 | "data": {
191 | "method": "get",
192 | "path": "/",
193 | "request": {
194 | "headers": {
195 | "Content-Type": "application/json",
196 | "X-Expires-At": "Christmas",
197 | "X-Rate-Limit": "100"
198 | }
199 | },
200 | "response": {
201 | "status": 200,
202 | "headers": {
203 | "Content-Type": "application/json"
204 | },
205 | "body": "{}"
206 | }
207 | },
208 | "valid": false
209 | },
210 | {
211 | "description": "request with valid header",
212 | "data": {
213 | "method": "get",
214 | "path": "/parent",
215 | "request": {
216 | "headers": {
217 | "Content-Type": "application/json",
218 | "X-Expires-At": "2020-01-01T00:00:00Z",
219 | "X-Rate-Limit": "100"
220 | }
221 | },
222 | "response": {
223 | "status": 200,
224 | "headers": {
225 | "Content-Type": "application/json"
226 | },
227 | "body": "{}"
228 | }
229 | },
230 | "valid": true
231 | },
232 | {
233 | "description": "request with valid header",
234 | "data": {
235 | "method": "get",
236 | "path": "/parent",
237 | "request": {
238 | "headers": {
239 | "Content-Type": "application/json",
240 | "X-Expires-At": "2020-01-01T00:00:00Z",
241 | "X-Rate-Limit": "10"
242 | }
243 | },
244 | "response": {
245 | "status": 200,
246 | "headers": {
247 | "Content-Type": "application/json"
248 | },
249 | "body": "{}"
250 | }
251 | },
252 | "valid": true
253 | },
254 | {
255 | "description": "request with invalid header",
256 | "data": {
257 | "method": "get",
258 | "path": "/parent",
259 | "request": {
260 | "headers": {
261 | "Content-Type": "application/json",
262 | "X-Expires-At": "2020-01-01T00:00:00Z",
263 | "X-Rate-Limit": "1"
264 | }
265 | },
266 | "response": {
267 | "status": 200,
268 | "headers": {
269 | "Content-Type": "application/json"
270 | },
271 | "body": "{}"
272 | }
273 | },
274 | "valid": false
275 | },
276 | {
277 | "description": "request with invalid header",
278 | "data": {
279 | "method": "get",
280 | "path": "/parent",
281 | "request": {
282 | "headers": {
283 | "Content-Type": "application/json",
284 | "X-Expires-At": "Christmas",
285 | "X-Rate-Limit": "100"
286 | }
287 | },
288 | "response": {
289 | "status": 200,
290 | "headers": {
291 | "Content-Type": "application/json"
292 | },
293 | "body": "{}"
294 | }
295 | },
296 | "valid": false
297 | }
298 | ]
299 | }
300 | ]
301 |
--------------------------------------------------------------------------------
/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/spec/openapi_test_suite/simple_routing.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "description": "Simple routing",
4 | "schema": {
5 | "openapi": "3.1.0",
6 | "info": {
7 | "title": "api",
8 | "version": "1.0.0"
9 | },
10 | "paths": {
11 | "/simple": {
12 | "get": {
13 | "responses": {
14 | "200": {
15 | "description": "Success",
16 | "content": {
17 | "application/json": {
18 | "schema": {
19 | "type": "object",
20 | "unevaluatedProperties": false,
21 | "properties": {
22 | "foo": {
23 | "type": "string"
24 | },
25 | "bar": {
26 | "type": "integer"
27 | }
28 | }
29 | }
30 | }
31 | }
32 | }
33 | }
34 | },
35 | "post": {
36 | "requestBody": {
37 | "required": true,
38 | "content": {
39 | "application/json": {
40 | "schema": {
41 | "type": "object",
42 | "unevaluatedProperties": false,
43 | "properties": {
44 | "foo": {
45 | "type": "string"
46 | },
47 | "bar": {
48 | "type": "integer"
49 | }
50 | }
51 | }
52 | }
53 | }
54 | },
55 | "responses": {
56 | "200": {
57 | "description": "Success",
58 | "content": {
59 | "application/json": {
60 | "schema": {
61 | "type": "object",
62 | "unevaluatedProperties": false,
63 | "properties": {
64 | "foo": {
65 | "type": "string"
66 | },
67 | "bar": {
68 | "type": "integer"
69 | }
70 | }
71 | }
72 | }
73 | }
74 | },
75 | "422": {
76 | "description": "Unprocessable Entity",
77 | "content": {
78 | "application/json": {
79 | "schema": {
80 | "type": "object",
81 | "unevaluatedProperties": false,
82 | "properties": {
83 | "message": {
84 | "type": "string"
85 | }
86 | }
87 | }
88 | }
89 | }
90 | }
91 | }
92 | }
93 | },
94 | "/simple_with_ref": {
95 | "get": {
96 | "responses": {
97 | "200": {
98 | "description": "OK",
99 | "content": {
100 | "application/json": {
101 | "schema": {
102 | "$ref": "#/components/schemas/Foo"
103 | }
104 | }
105 | }
106 | }
107 | }
108 | },
109 | "post": {
110 | "requestBody": {
111 | "content": {
112 | "application/json": {
113 | "schema": {
114 | "$ref": "#/components/schemas/Foo"
115 | }
116 | }
117 | }
118 | },
119 | "responses": {
120 | "200": {
121 | "description": "OK",
122 | "content": {
123 | "application/json": {
124 | "schema": {
125 | "$ref": "#/components/schemas/Foo"
126 | }
127 | }
128 | }
129 | },
130 | "422": {
131 | "description": "Unprocessable Entity",
132 | "content": {
133 | "application/json": {
134 | "schema": {
135 | "type": "object",
136 | "unevaluatedProperties": false,
137 | "properties": {
138 | "message": {
139 | "type": "string"
140 | }
141 | }
142 | }
143 | }
144 | }
145 | }
146 | }
147 | }
148 | },
149 | "/no_responses": {
150 | "get": {}
151 | },
152 | "/with_default_response": {
153 | "get": {
154 | "responses": {
155 | "default": {
156 | "description": "Success",
157 | "content": {
158 | "application/json": {
159 | "schema": {
160 | "type": "object",
161 | "unevaluatedProperties": false,
162 | "properties": {
163 | "message": {
164 | "type": "string"
165 | }
166 | }
167 | }
168 | }
169 | }
170 | }
171 | }
172 | }
173 | }
174 | },
175 | "components": {
176 | "schemas": {
177 | "Foo": {
178 | "type": "object",
179 | "unevaluatedProperties": false,
180 | "properties": {
181 | "foo": {
182 | "type": "string"
183 | },
184 | "bar": {
185 | "type": "integer"
186 | }
187 | }
188 | }
189 | }
190 | }
191 | },
192 | "tests": [
193 | {
194 | "description": "request with valid response body",
195 | "data": {
196 | "method": "get",
197 | "path": "/simple",
198 | "request": {
199 | "headers": {
200 | "Content-Type": "application/json"
201 | }
202 | },
203 | "response": {
204 | "status": 200,
205 | "headers": {
206 | "Content-Type": "application/json"
207 | },
208 | "body": "{\"foo\": \"foo\", \"bar\": 1}"
209 | }
210 | },
211 | "valid": true
212 | },
213 | {
214 | "description": "request with invalid response body",
215 | "data": {
216 | "method": "get",
217 | "path": "/simple",
218 | "request": {
219 | "headers": {
220 | "Content-Type": "application/json"
221 | }
222 | },
223 | "response": {
224 | "status": 200,
225 | "headers": {
226 | "Content-Type": "application/json"
227 | },
228 | "body": "{\"foo\": \"foo\", \"bar\": \"bar\"}"
229 | }
230 | },
231 | "valid": false
232 | },
233 | {
234 | "description": "schema that uses $ref for response and request with valid response body",
235 | "data": {
236 | "method": "get",
237 | "path": "/simple_with_ref",
238 | "request": {
239 | "headers": {
240 | "Content-Type": "application/json"
241 | }
242 | },
243 | "response": {
244 | "status": 200,
245 | "headers": {
246 | "Content-Type": "application/json"
247 | },
248 | "body": "{\"foo\": \"foo\", \"bar\": 2}"
249 | }
250 | },
251 | "valid": true
252 | },
253 | {
254 | "description": "schema that uses $ref for response and request with invalid response body",
255 | "data": {
256 | "method": "get",
257 | "path": "/simple_with_ref",
258 | "request": {
259 | "headers": {
260 | "Content-Type": "application/json"
261 | }
262 | },
263 | "response": {
264 | "status": 200,
265 | "headers": {
266 | "Content-Type": "application/json"
267 | },
268 | "body": "{\"foo\": \"foo\", \"bar\": \"bar\"}"
269 | }
270 | },
271 | "valid": false
272 | },
273 | {
274 | "description": "post request with valid request body",
275 | "data": {
276 | "method": "post",
277 | "path": "/simple",
278 | "request": {
279 | "headers": {
280 | "Content-Type": "application/json"
281 | },
282 | "body": "{\"foo\": \"foo\", \"bar\": 2}"
283 | },
284 | "response": {
285 | "status": 200,
286 | "headers": {
287 | "Content-Type": "application/json"
288 | },
289 | "body": "{\"foo\": \"foo\", \"bar\": 2}"
290 | }
291 | },
292 | "valid": true
293 | },
294 | {
295 | "description": "post request with invalid request body",
296 | "data": {
297 | "method": "post",
298 | "path": "/simple",
299 | "request": {
300 | "headers": {
301 | "Content-Type": "application/json"
302 | },
303 | "body": "{\"foo\": \"foo\", \"bar\": \"bar\"}"
304 | },
305 | "response": {
306 | "status": 422,
307 | "headers": {
308 | "Content-Type": "application/json"
309 | },
310 | "body": "{\"message\": \"validation error\"}"
311 | }
312 | },
313 | "valid": false
314 | },
315 | {
316 | "description": "post request without required request body",
317 | "data": {
318 | "method": "post",
319 | "path": "/simple",
320 | "request": {
321 | "headers": {
322 | "Content-Type": "application/json"
323 | }
324 | },
325 | "response": {
326 | "status": 422,
327 | "headers": {
328 | "Content-Type": "application/json"
329 | },
330 | "body": "{\"message\": \"validation error\"}"
331 | }
332 | },
333 | "valid": false
334 | },
335 | {
336 | "description": "post request without optional request body",
337 | "data": {
338 | "method": "post",
339 | "path": "/simple_with_ref",
340 | "request": {
341 | "headers": {
342 | "Content-Type": "application/json"
343 | }
344 | },
345 | "response": {
346 | "status": 422,
347 | "headers": {
348 | "Content-Type": "application/json"
349 | },
350 | "body": "{\"message\": \"validation error\"}"
351 | }
352 | },
353 | "valid": true
354 | },
355 | {
356 | "description": "with default response",
357 | "data": {
358 | "method": "get",
359 | "path": "/with_default_response",
360 | "request": {
361 | "headers": {
362 | "Content-Type": "application/json"
363 | }
364 | },
365 | "response": {
366 | "status": 200,
367 | "headers": {
368 | "Content-Type": "application/json"
369 | },
370 | "body": "{\"message\": \"ok\"}"
371 | }
372 | },
373 | "valid": true
374 | },
375 | {
376 | "description": "undocumented route path",
377 | "data": {
378 | "method": "get",
379 | "path": "/unknown_path",
380 | "request": {
381 | "headers": {
382 | "Content-Type": "application/json"
383 | }
384 | },
385 | "response": {
386 | "status": 404
387 | }
388 | },
389 | "valid": false
390 | },
391 | {
392 | "description": "undocumented route method",
393 | "data": {
394 | "method": "patch",
395 | "path": "/simple_with_ref",
396 | "request": {
397 | "headers": {
398 | "Content-Type": "application/json"
399 | }
400 | },
401 | "response": {
402 | "status": 404
403 | }
404 | },
405 | "valid": false
406 | },
407 | {
408 | "description": "undocumented route response code",
409 | "data": {
410 | "method": "post",
411 | "path": "/simple_with_ref",
412 | "request": {
413 | "headers": {
414 | "Content-Type": "application/json"
415 | }
416 | },
417 | "response": {
418 | "status": 418
419 | }
420 | },
421 | "valid": false
422 | },
423 | {
424 | "description": "no responses in schema",
425 | "data": {
426 | "method": "get",
427 | "path": "/no_responses",
428 | "request": {
429 | "headers": {
430 | "Content-Type": "application/json"
431 | }
432 | },
433 | "response": {
434 | "status": 418
435 | }
436 | },
437 | "valid": false
438 | },
439 | {
440 | "description": "no responses in schema request only",
441 | "data": {
442 | "method": "get",
443 | "path": "/no_responses",
444 | "request": {
445 | "headers": {
446 | "Content-Type": "application/json"
447 | }
448 | }
449 | },
450 | "valid": true
451 | }
452 | ]
453 | }
454 | ]
455 |
--------------------------------------------------------------------------------