├── 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 | [![Gem Version](https://badge.fury.io/rb/skooma.svg)](https://rubygems.org/gems/skooma) 4 | [![Ruby](https://github.com/skryukov/skooma/actions/workflows/main.yml/badge.svg)](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 | Sponsored by Evil Martians 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 | --------------------------------------------------------------------------------