├── spec ├── apps │ ├── rails │ │ ├── log │ │ │ └── .keep │ │ ├── tmp │ │ │ ├── .keep │ │ │ └── pids │ │ │ │ └── .keep │ │ ├── vendor │ │ │ ├── .keep │ │ │ └── my_engine │ │ │ │ ├── lib │ │ │ │ └── my_engine │ │ │ │ │ └── engine.rb │ │ │ │ └── config │ │ │ │ └── routes.rb │ │ ├── lib │ │ │ ├── assets │ │ │ │ └── .keep │ │ │ ├── tasks │ │ │ │ └── .keep │ │ │ └── rack_test │ │ │ │ └── app.rb │ │ ├── storage │ │ │ └── .keep │ │ ├── public │ │ │ ├── favicon.ico │ │ │ ├── apple-touch-icon.png │ │ │ ├── apple-touch-icon-precomposed.png │ │ │ ├── robots.txt │ │ │ ├── 500.html │ │ │ ├── 422.html │ │ │ └── 404.html │ │ ├── app │ │ │ ├── assets │ │ │ │ ├── images │ │ │ │ │ └── .keep │ │ │ │ ├── config │ │ │ │ │ └── manifest.js │ │ │ │ └── stylesheets │ │ │ │ │ └── application.css │ │ │ ├── models │ │ │ │ ├── concerns │ │ │ │ │ └── .keep │ │ │ │ └── application_record.rb │ │ │ ├── controllers │ │ │ │ ├── concerns │ │ │ │ │ └── .keep │ │ │ │ ├── secret_items_controller.rb │ │ │ │ ├── application_controller.rb │ │ │ │ ├── masters │ │ │ │ │ └── extensions_controller.rb │ │ │ │ ├── pages_controller.rb │ │ │ │ ├── invalid_responses_controller.rb │ │ │ │ ├── additional_properties_controller.rb │ │ │ │ ├── sites_controller.rb │ │ │ │ ├── images_controller.rb │ │ │ │ ├── users_controller.rb │ │ │ │ ├── tables_controller.rb │ │ │ │ └── array_hashes_controller.rb │ │ │ ├── views │ │ │ │ └── layouts │ │ │ │ │ ├── mailer.text.erb │ │ │ │ │ ├── mailer.html.erb │ │ │ │ │ └── application.html.erb │ │ │ ├── helpers │ │ │ │ └── application_helper.rb │ │ │ ├── channels │ │ │ │ └── application_cable │ │ │ │ │ ├── channel.rb │ │ │ │ │ └── connection.rb │ │ │ ├── mailers │ │ │ │ └── application_mailer.rb │ │ │ ├── javascript │ │ │ │ ├── channels │ │ │ │ │ ├── index.js │ │ │ │ │ └── consumer.js │ │ │ │ └── packs │ │ │ │ │ └── application.js │ │ │ └── jobs │ │ │ │ └── application_job.rb │ │ ├── config │ │ │ ├── initializers │ │ │ │ ├── rack_test.rb │ │ │ │ ├── mime_types.rb │ │ │ │ ├── filter_parameter_logging.rb │ │ │ │ ├── application_controller_renderer.rb │ │ │ │ ├── cookies_serializer.rb │ │ │ │ ├── backtrace_silencers.rb │ │ │ │ ├── wrap_parameters.rb │ │ │ │ ├── inflections.rb │ │ │ │ └── content_security_policy.rb │ │ │ ├── spring.rb │ │ │ ├── boot.rb │ │ │ ├── environment.rb │ │ │ ├── cable.yml │ │ │ ├── credentials.yml.enc │ │ │ ├── database.yml │ │ │ ├── locales │ │ │ │ └── en.yml │ │ │ ├── storage.yml │ │ │ ├── application.rb │ │ │ ├── puma.rb │ │ │ ├── routes.rb │ │ │ └── environments │ │ │ │ ├── test.rb │ │ │ │ ├── development.rb │ │ │ │ └── production.rb │ │ ├── doc │ │ │ ├── screenshot.png │ │ │ └── smart │ │ │ │ └── openapi.yaml │ │ ├── bin │ │ │ ├── rake │ │ │ ├── rails │ │ │ ├── yarn │ │ │ └── setup │ │ ├── config.ru │ │ ├── package.json │ │ ├── Rakefile │ │ ├── db │ │ │ └── seeds.rb │ │ └── .gitignore │ ├── hanami │ │ ├── lib │ │ │ ├── tasks │ │ │ │ └── .keep │ │ │ ├── hanami_test │ │ │ │ └── types.rb │ │ │ └── rack_test │ │ │ │ └── app.rb │ │ ├── app │ │ │ ├── actions │ │ │ │ ├── .keep │ │ │ │ ├── extensions │ │ │ │ │ ├── index.rb │ │ │ │ │ └── create.rb │ │ │ │ ├── sites │ │ │ │ │ ├── show.rb │ │ │ │ │ ├── site_repository.rb │ │ │ │ │ └── site_action.rb │ │ │ │ ├── users │ │ │ │ │ ├── show.rb │ │ │ │ │ ├── active.rb │ │ │ │ │ ├── user_action.rb │ │ │ │ │ ├── create.rb │ │ │ │ │ └── user_repository.rb │ │ │ │ ├── tables │ │ │ │ │ ├── show.rb │ │ │ │ │ ├── update.rb │ │ │ │ │ ├── destroy.rb │ │ │ │ │ ├── index.rb │ │ │ │ │ ├── create.rb │ │ │ │ │ ├── table_action.rb │ │ │ │ │ └── table_repository.rb │ │ │ │ ├── secret_items │ │ │ │ │ └── index.rb │ │ │ │ ├── array_hashes │ │ │ │ │ ├── empty_array.rb │ │ │ │ │ ├── non_hash_items.rb │ │ │ │ │ ├── single_item.rb │ │ │ │ │ ├── non_nullable.rb │ │ │ │ │ ├── nested_arrays.rb │ │ │ │ │ ├── nullable.rb │ │ │ │ │ ├── nested.rb │ │ │ │ │ ├── mixed_types_nested.rb │ │ │ │ │ └── nested_objects.rb │ │ │ │ └── images │ │ │ │ │ ├── index.rb │ │ │ │ │ ├── show.rb │ │ │ │ │ ├── upload_nested.rb │ │ │ │ │ ├── upload_multiple_nested.rb │ │ │ │ │ ├── upload.rb │ │ │ │ │ └── upload_multiple.rb │ │ │ └── action.rb │ │ ├── .gitignore │ │ ├── slices │ │ │ └── my_engine │ │ │ │ ├── actions │ │ │ │ ├── .keep │ │ │ │ └── eng │ │ │ │ │ └── example.rb │ │ │ │ └── action.rb │ │ ├── Procfile.dev │ │ ├── Rakefile │ │ ├── test.png │ │ ├── config.ru │ │ ├── bin │ │ │ └── dev │ │ ├── config │ │ │ ├── app.rb │ │ │ ├── settings.rb │ │ │ ├── puma.rb │ │ │ └── routes.rb │ │ └── public │ │ │ ├── 500.html │ │ │ └── 404.html │ └── roda │ │ ├── doc │ │ ├── rspec_openapi.rb │ │ ├── minitest_openapi.yaml │ │ ├── rspec_openapi.yaml │ │ ├── minitest_openapi.json │ │ └── rspec_openapi.json │ │ └── roda_app.rb ├── integration_tests │ └── roda_test.rb ├── requests │ ├── roda_spec.rb │ ├── rspec_hook_error_spec.rb │ └── rails_smart_merge_spec.rb ├── rspec │ ├── rack_test_spec.rb │ ├── hanami_spec.rb │ ├── schema_file_spec.rb │ ├── scheme_merger_spec.rb │ └── rails_spec.rb ├── minitest │ ├── rack_test_spec.rb │ └── rails_spec.rb └── spec_helper.rb ├── test.png ├── .rspec ├── lib └── rspec │ ├── openapi │ ├── extractors.rb │ ├── version.rb │ ├── shared_hooks.rb │ ├── default_schema.rb │ ├── key_transformer.rb │ ├── rspec_hooks.rb │ ├── schema_sorter.rb │ ├── schema_file.rb │ ├── hash_helper.rb │ ├── minitest_hooks.rb │ ├── record.rb │ ├── extractors │ │ ├── rack.rb │ │ ├── rails.rb │ │ └── hanami.rb │ ├── result_recorder.rb │ ├── record_builder.rb │ ├── schema_merger.rb │ ├── components_updater.rb │ └── schema_cleaner.rb │ └── openapi.rb ├── bin ├── setup └── console ├── .github ├── dependabot.yml ├── release.yaml └── workflows │ ├── rubocop.yml │ ├── codeql-analysis.yml │ ├── publish.yml │ ├── test.yml │ └── create_release.yml ├── .gitignore ├── Rakefile ├── scripts ├── rspec └── rspec_with_simplecov ├── .simplecov_spawn.rb ├── .rubocop.yml ├── Gemfile ├── LICENSE.txt ├── rspec-openapi.gemspec ├── .rubocop_todo.yml └── CHANGELOG.md /spec/apps/rails/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/tmp/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/vendor/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/hanami/lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/tmp/pids/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/hanami/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | log/* 3 | -------------------------------------------------------------------------------- /spec/apps/hanami/slices/my_engine/actions/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/rails/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/apps/hanami/Procfile.dev: -------------------------------------------------------------------------------- 1 | web: bundle exec hanami server 2 | -------------------------------------------------------------------------------- /spec/apps/rails/app/views/layouts/mailer.text.erb: -------------------------------------------------------------------------------- 1 | <%= yield %> 2 | -------------------------------------------------------------------------------- /test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoego/rspec-openapi/HEAD/test.png -------------------------------------------------------------------------------- /spec/apps/rails/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /spec/apps/hanami/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "hanami/rake_tasks" 4 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/rack_test.rb: -------------------------------------------------------------------------------- 1 | require Rails.root.join('lib/rack_test/app') 2 | -------------------------------------------------------------------------------- /spec/apps/hanami/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoego/rspec-openapi/HEAD/spec/apps/hanami/test.png -------------------------------------------------------------------------------- /spec/apps/hanami/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hanami/boot' 4 | 5 | run Hanami.app 6 | -------------------------------------------------------------------------------- /spec/apps/rails/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --exclude-pattern spec/requests/**/*_spec.rb 2 | --format documentation 3 | --require rspec/openapi 4 | --color 5 | -------------------------------------------------------------------------------- /spec/apps/rails/doc/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/exoego/rspec-openapi/HEAD/spec/apps/rails/doc/screenshot.png -------------------------------------------------------------------------------- /spec/apps/rails/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/apps/rails/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /spec/apps/rails/public/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | -------------------------------------------------------------------------------- /lib/rspec/openapi/extractors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Create namespace 4 | module RSpec::OpenAPI::Extractors 5 | end 6 | -------------------------------------------------------------------------------- /lib/rspec/openapi/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RSpec 4 | module OpenAPI 5 | VERSION = '0.21.4' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/apps/rails/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/apps/rails/config/spring.rb: -------------------------------------------------------------------------------- 1 | Spring.watch( 2 | ".ruby-version", 3 | ".rbenv-vars", 4 | "tmp/restart.txt", 5 | "tmp/caching-dev.txt", 6 | ) 7 | -------------------------------------------------------------------------------- /spec/apps/rails/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/apps/rails/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: 'from@example.com' 3 | layout 'mailer' 4 | end 5 | -------------------------------------------------------------------------------- /spec/apps/roda/doc/rspec_openapi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::OpenAPI.title = 'Override title' 4 | RSpec::OpenAPI.application_version = '7.7.7' 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /spec/apps/rails/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/apps/rails/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/apps/rails/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/apps/hanami/slices/my_engine/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | module MyEngine 5 | class Action < HanamiTest::Action 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/apps/rails/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/secret_items_controller.rb: -------------------------------------------------------------------------------- 1 | class SecretItemsController < ApplicationController 2 | def index 3 | render json: { items: ['secrets'] } 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | labels: 8 | - "chore" 9 | -------------------------------------------------------------------------------- /spec/apps/rails/vendor/my_engine/lib/my_engine/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MyEngine 4 | class Engine < ::Rails::Engine 5 | isolate_namespace MyEngine 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/apps/hanami/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | if ! gem list foreman -i --silent; then 4 | echo "Installing foreman..." 5 | gem install foreman 6 | fi 7 | 8 | exec foreman start -f Procfile.dev "$@" 9 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/apps/hanami/config/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'hanami' 4 | 5 | module HanamiTest 6 | class App < Hanami::App 7 | config.middleware.use :body_parser, :json 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/action.rb: -------------------------------------------------------------------------------- 1 | # auto_register: false 2 | # frozen_string_literal: true 3 | 4 | require 'hanami/action' 5 | 6 | class HanamiTest::Action < Hanami::Action 7 | class RecordNotFound < StandardError; end 8 | end 9 | -------------------------------------------------------------------------------- /spec/apps/rails/vendor/my_engine/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | MyEngine::Engine.routes.draw do 4 | get '/eng_route' => ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['AN ENGINE TEST']] } 5 | end 6 | -------------------------------------------------------------------------------- /spec/apps/hanami/lib/hanami_test/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "dry/types" 4 | 5 | module HanamiTest 6 | Types = Dry.Types 7 | 8 | module Types 9 | # Define your custom types here 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /Gemfile.lock 10 | /vendor/ 11 | .DS_Store 12 | 13 | # rspec failure tracking 14 | .rspec_status 15 | 16 | spec/apps/rails/log/ 17 | -------------------------------------------------------------------------------- /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) do |t| 7 | t.pattern = 'spec/rspec/openapi/**/*_spec.rb' 8 | end 9 | 10 | task default: :spec 11 | -------------------------------------------------------------------------------- /spec/apps/rails/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: railsapp_production 11 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /spec/apps/rails/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "railsapp", 3 | "private": true, 4 | "dependencies": { 5 | "@rails/ujs": "^6.0.0", 6 | "@rails/activestorage": "^6.0.0", 7 | "@rails/actioncable": "^6.0.0" 8 | }, 9 | "version": "0.1.0" 10 | } 11 | -------------------------------------------------------------------------------- /spec/apps/rails/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | NotFoundError = Class.new(StandardError) 3 | 4 | rescue_from NotFoundError do 5 | render json: { message: 'not found' }, status: 404 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/apps/rails/app/javascript/channels/index.js: -------------------------------------------------------------------------------- 1 | // Load all the channels within this directory and all subdirectories. 2 | // Channel files must be named *_channel.js. 3 | 4 | const channels = require.context('.', true, /_channel\.js$/) 5 | channels.keys().forEach(channels) 6 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/masters/extensions_controller.rb: -------------------------------------------------------------------------------- 1 | class Masters::ExtensionsController < ApplicationController 2 | def index 3 | render json: [{ name: 'my-ext-1' }] 4 | end 5 | 6 | def create 7 | render json: { message: 'created' } 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/apps/hanami/config/settings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | class Settings < Hanami::Settings 5 | # Define your app settings here, for example: 6 | # 7 | # setting :my_flag, default: false, constructor: Types::Params::Bool 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /spec/apps/rails/app/javascript/channels/consumer.js: -------------------------------------------------------------------------------- 1 | // Action Cable provides the framework to deal with WebSockets in Rails. 2 | // You can generate new channels where WebSocket features live using the `rails generate channel` command. 3 | 4 | import { createConsumer } from "@rails/actioncable" 5 | 6 | export default createConsumer() 7 | -------------------------------------------------------------------------------- /spec/apps/rails/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /spec/apps/rails/app/views/layouts/mailer.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | <%= yield %> 12 | 13 | 14 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | class PagesController < ApplicationController 2 | def get 3 | if params[:head] == '1' 4 | head :no_content 5 | else 6 | render html: 'HelloHello'.html_safe 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/apps/rails/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_ROOT = File.expand_path('..', __dir__) 3 | Dir.chdir(APP_ROOT) do 4 | exec "yarnpkg", *ARGV 5 | rescue Errno::ENOENT 6 | $stderr.puts "Yarn executable was not detected in the system." 7 | $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" 8 | exit 1 9 | end 10 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/invalid_responses_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class InvalidResponsesController < ApplicationController 4 | def show 5 | render json: { 6 | payload: { 7 | message: 'invalid payload example', 8 | requested_at: Time.current, 9 | }, 10 | } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/extensions/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Extensions 6 | class Index < HanamiTest::Action 7 | def handle(_request, response) 8 | response.body = { message: 'created' }.to_json 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/additional_properties_controller.rb: -------------------------------------------------------------------------------- 1 | class AdditionalPropertiesController < ApplicationController 2 | def index 3 | response = { 4 | required_key: 'value', 5 | variadic_key: { 6 | gold: 1, 7 | silver: 2, 8 | bronze: 3 9 | } 10 | } 11 | render json: response 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/extensions/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Extensions 6 | class Create < HanamiTest::Action 7 | def handle(_request, response) 8 | response.body = [{ name: 'my-ext-1' }].to_json 9 | end 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/sites/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Sites 6 | class Show < SiteAction 7 | format :json 8 | 9 | def handle(request, response) 10 | response.body = find_site(request.params[:name]).to_json 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/users/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Users 6 | class Show < UserAction 7 | format :json 8 | 9 | def handle(request, response) 10 | response.body = find_user(request.params[:id]).to_json 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class Show < TableAction 7 | format :json 8 | 9 | def handle(request, response) 10 | response.body = find_table(request.params[:id]).to_json 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/rails/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Railsapp 5 | <%= csrf_meta_tags %> 6 | <%= csp_meta_tag %> 7 | 8 | <%= stylesheet_link_tag 'application', media: 'all' %> 9 | <%= javascript_pack_tag 'application' %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /scripts/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # (The MIT License) 5 | # Copyright (c) 2012 Chad Humphries, David Chelimsky, Myron Marston 6 | # Copyright (c) 2009 Chad Humphries, David Chelimsky 7 | # Copyright (c) 2006 David Chelimsky, The RSpec Development Team 8 | # Copyright (c) 2005 Steven Baker 9 | 10 | require 'rspec/core' 11 | RSpec::Core::Runner.invoke 12 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/secret_items/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module SecretItems 6 | class Index < HanamiTest::Action 7 | format :json 8 | 9 | def handle(_request, response) 10 | response.body = { items: ['secrets'] }.to_json 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/update.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class Update < TableAction 7 | def handle(request, response) 8 | response.format = :json 9 | response.body = find_table(request.params[:id]).to_json 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/sites_controller.rb: -------------------------------------------------------------------------------- 1 | class SitesController < ApplicationController 2 | def show 3 | render json: find_site(params[:name]) 4 | end 5 | 6 | private 7 | 8 | def find_site(name = nil) 9 | case name 10 | when 'abc123', nil 11 | { 12 | name: name, 13 | } 14 | else 15 | raise NotFoundError 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/apps/hanami/slices/my_engine/actions/eng/example.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module MyEngine 4 | module Actions 5 | module Eng 6 | class Example < MyEngine::Action 7 | def handle(_request, response) 8 | response.headers['Content-Type'] = 'text/plain' 9 | response.body = 'AN ENGINE TEST' 10 | end 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/apps/rails/db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) 7 | # Character.create(name: 'Luke', movie: movies.first) 8 | -------------------------------------------------------------------------------- /spec/apps/roda/roda_app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'roda' 4 | 5 | class RodaApp < Roda 6 | plugin :json, classes: [Array, Hash] 7 | 8 | route do |r| 9 | r.on 'roda' do 10 | # POST /roda 11 | r.post do 12 | params = JSON.parse(request.body.read, symbolize_names: true) 13 | params.merge({ name: 'hello' }) 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/users/active.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Users 6 | class Active < UserAction 7 | format :json 8 | 9 | def handle(request, response) 10 | response.body = find_user(request.params[:id]).present?.to_json # present not exist in hanami 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/empty_array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class EmptyArray < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => [] 12 | }.to_json 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'rspec/openapi' 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 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/apps/rails/lib/rack_test/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RackTest 4 | class App 5 | def call(env) 6 | req = Rack::Request.new(env) 7 | path = req.path_info 8 | 9 | case path 10 | when "/foo" 11 | [200, { 'Content-Type' => 'text/plain' }, ['A RACK FOO']] 12 | when "/bar" 13 | [200, { 'Content-Type' => 'text/plain' }, ['A RACK BAR']] 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/non_hash_items.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class NonHashItems < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => ["string1", "string2", "string3"] 12 | }.to_json 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.simplecov_spawn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless ENV['COVERAGE'] && ENV['COVERAGE'].empty? 4 | require 'simplecov' 5 | require 'simplecov-cobertura' 6 | 7 | SimpleCov.at_fork.call(Process.pid) 8 | SimpleCov.formatter SimpleCov::Formatter::MultiFormatter.new([ 9 | SimpleCov::Formatter::CoberturaFormatter, 10 | ]) 11 | SimpleCov.start do 12 | enable_coverage :branch 13 | add_filter '/spec/' 14 | add_filter '/scripts/' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/apps/rails/config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | S2PiCpOvCm4PIsh+QqHIxoI2ALTsQI4DoJLbB8i01jlV6/U2ZfFvNU12hWc027xI9180jLgzJxUR6UntRRVN8JgBMO737ecnbjYodGJJX97achUnW7O/iapB3YDwv3MXwj9CdL6BKnvxzdzTeZly8JI5wAj86tpuxMS7XyV38+geq2V3qxK3xmEtuVyPBzxZsgPOR5S77RbFsp7my7EFUYjuvQOVB0HRUbVaIIzaPu2VmRV65jXNaNIQ0LjertHI14GGs084M21w05+LByg5kMcm9xNrPC63OWpgKOGYbm8lICDWz5X7XkMsaBvbZwtXriOstIyOXslsyqXsenjfR4Af+a0AD0EwGmj8cYbrdjyQX28gDIGZgjkhU3nDapaDmanOVpfB14i3UK33ucwmcLvN0nvUzMJU9/E5--Uc6odNOssdbCwm6d--dJP2D3Hakf5LEn78EPoqeQ== -------------------------------------------------------------------------------- /lib/rspec/openapi/shared_hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module SharedHooks 4 | def self.find_extractor 5 | if defined?(Rails) && Rails.respond_to?(:application) && Rails.application 6 | RSpec::OpenAPI::Extractors::Rails 7 | elsif defined?(Hanami) && Hanami.respond_to?(:app) && Hanami.app? 8 | RSpec::OpenAPI::Extractors::Hanami 9 | # elsif defined?(Roda) 10 | # some Roda extractor 11 | else 12 | RSpec::OpenAPI::Extractors::Rack 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/destroy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class Destroy < TableAction 7 | def handle(request, response) 8 | response.format = :json 9 | if request.params[:no_content] 10 | response.status = 202 11 | else 12 | response.body = find_table(request.params[:id]).to_json 13 | end 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/sites/site_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Sites 6 | module SiteRepository 7 | class RecordNotFound < StandardError; end 8 | 9 | def find_site(name = nil) 10 | case name 11 | when 'abc123', nil 12 | { 13 | name: name, 14 | } 15 | else 16 | raise RecordNotFound 17 | end 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class Index < HanamiTest::Action 7 | format :json 8 | 9 | def handle(_request, response) 10 | list = [ 11 | { 12 | name: 'file.png', 13 | tags: [], # Keep this empty to check empty array is accepted 14 | }, 15 | ] 16 | 17 | response.body = list.to_json 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/sites/site_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Sites 6 | class SiteAction < HanamiTest::Action 7 | include SiteRepository 8 | 9 | handle_exception RecordNotFound => :handle_not_fount_error 10 | 11 | private 12 | 13 | def handle_not_fount_error(_request, response, _exception) 14 | response.status = 404 15 | response.body = { message: 'not found' }.to_json 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/single_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class SingleItem < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => [ 12 | { 13 | "id" => 1, 14 | "name" => "Item 1" 15 | } 16 | ] 17 | }.to_json 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/apps/hanami/lib/rack_test/app.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RackTest 4 | class App 5 | def initialize(app) 6 | @app = app 7 | end 8 | 9 | def call(env) 10 | req = Rack::Request.new(env) 11 | path = req.path_info 12 | 13 | case path 14 | when "/rack/foo" 15 | [200, { 'Content-Type' => 'text/plain' }, ['A RACK FOO']] 16 | when "/rack/bar" 17 | [200, { 'Content-Type' => 'text/plain' }, ['A RACK BAR']] 18 | else 19 | return @app.call(env) 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /.github/release.yaml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: 🛠 Breaking Changes 9 | labels: 10 | - semver-major 11 | - breaking-change 12 | - title: 🎉 Exciting New Features 13 | labels: 14 | - semver-minor 15 | - enhancement 16 | - title: 🐞 Bugfixes 17 | labels: 18 | - bug 19 | - title: 📄 Documentation 20 | labels: 21 | - documentation 22 | - title: 📦 Other Changes 23 | labels: 24 | - "*" 25 | -------------------------------------------------------------------------------- /lib/rspec/openapi/default_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class << RSpec::OpenAPI::DefaultSchema = Object.new 4 | def build(title) 5 | spec = { 6 | openapi: '3.0.3', 7 | info: { 8 | title: title, 9 | version: RSpec::OpenAPI.application_version, 10 | }, 11 | servers: RSpec::OpenAPI.servers, 12 | paths: {}, 13 | } 14 | 15 | if RSpec::OpenAPI.security_schemes.present? 16 | spec[:components] = { 17 | securitySchemes: RSpec::OpenAPI.security_schemes, 18 | } 19 | end 20 | 21 | spec.freeze 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/rspec/openapi/key_transformer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class << RSpec::OpenAPI::KeyTransformer = Object.new 4 | def symbolize(value) 5 | case value 6 | when Hash 7 | value.to_h { |k, v| [k.to_sym, symbolize(v)] } 8 | when Array 9 | value.map { |v| symbolize(v) } 10 | else 11 | value 12 | end 13 | end 14 | 15 | def stringify(value) 16 | case value 17 | when Hash 18 | value.to_h { |k, v| [k.to_s, stringify(v)] } 19 | when Array 20 | value.map { |v| stringify(v) } 21 | else 22 | value 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/users/user_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class UserAction < HanamiTest::Action 7 | include UserRepository 8 | 9 | handle_exception RecordNotFound => :handle_not_fount_error 10 | 11 | before :authenticate 12 | 13 | private 14 | 15 | def handle_not_fount_error(_request, response, _exception) 16 | response.status = 404 17 | response.body = { message: 'not found' }.to_json 18 | end 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/index.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class Index < TableAction 7 | def handle(request, response) 8 | response.headers['X-Cursor'] = 100 9 | 10 | response.format = :json 11 | 12 | response.body = if request.params[:show_columns] 13 | [find_table('42')].to_json 14 | else 15 | [find_table].to_json 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class Create < TableAction 7 | format :json 8 | 9 | def handle(request, response) 10 | if request.params[:name].blank? || request.params[:name] == 'some_invalid_name' 11 | response.status = 422 12 | response.body = { error: 'invalid name parameter' }.to_json 13 | else 14 | response.status = 201 15 | response.body = find_table.to_json 16 | end 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/non_nullable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class NonNullable < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = {"users" => [ 11 | { 12 | "label" => "Jane Doe", 13 | "value" => "jane_doe" 14 | }, 15 | { 16 | "label" => "John Doe", 17 | "value" => "john_doe", 18 | } 19 | ]}.to_json 20 | end 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/users/create.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Users 6 | class Create < UserAction 7 | format :json 8 | 9 | def handle(_request, response) 10 | res = { 11 | name: params[:name], 12 | relations: { 13 | avatar: { 14 | url: params[:avatar_url] || 'https://example.com/avatar.png', 15 | }, 16 | pets: params[:pets] || [], 17 | }, 18 | } 19 | 20 | response.status = 201 21 | response.body = res.to_json 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/show.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class Show < HanamiTest::Action 7 | def handle(_request, response) 8 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjPQBoAAAAASUVORK5CYII=' 9 | .unpack('m').first 10 | 11 | response.format = :png 12 | response.body = png 13 | response.headers.merge!( 14 | { 15 | 'Content-Type' => 'image/png', 16 | 'Content-Disposition' => 'inline', 17 | } 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | AllCops: 4 | NewCops: enable 5 | SuggestExtensions: false 6 | TargetRubyVersion: 2.7 7 | Exclude: 8 | - 'spec/apps/**/*' 9 | - 'vendor/**/*' 10 | 11 | Style/TrailingCommaInHashLiteral: 12 | EnforcedStyleForMultiline: consistent_comma 13 | Style/TrailingCommaInArguments: 14 | EnforcedStyleForMultiline: consistent_comma 15 | Style/TrailingCommaInArrayLiteral: 16 | EnforcedStyleForMultiline: consistent_comma 17 | Style/ClassAndModuleChildren: 18 | EnforcedStyle: compact 19 | Exclude: 20 | - 'lib/rspec/openapi/version.rb' 21 | Layout/FirstArrayElementIndentation: 22 | EnforcedStyle: consistent 23 | Metrics/BlockLength: 24 | Exclude: 25 | - 'spec/**/*' 26 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/upload_nested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class UploadNested < HanamiTest::Action 7 | def handle(_request, response) 8 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjPQBoAAAAASUVORK5CYII=' 9 | .unpack('m').first 10 | 11 | response.format = :png 12 | response.body = png 13 | response.headers.merge!( 14 | { 15 | 'Content-Type' => 'image/png', 16 | 'Content-Disposition' => 'inline', 17 | } 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/upload_multiple_nested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class UploadMultipleNested < HanamiTest::Action 7 | def handle(_request, response) 8 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjPQBoAAAAASUVORK5CYII=' 9 | .unpack('m').first 10 | 11 | response.format = :png 12 | response.body = png 13 | response.headers.merge!( 14 | { 15 | 'Content-Type' => 'image/png', 16 | 'Content-Disposition' => 'inline', 17 | } 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/upload.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class Upload < HanamiTest::Action 7 | # format :form 8 | 9 | def handle(_request, response) 10 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjPQBoAAAAASUVORK5CYII=' 11 | .unpack('m').first 12 | 13 | response.format = :png 14 | response.body = png 15 | response.headers.merge!( 16 | { 17 | 'Content-Type' => 'image/png', 18 | 'Content-Disposition' => 'inline', 19 | } 20 | ) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/apps/rails/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem 'sqlite3' 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/images/upload_multiple.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Images 6 | class UploadMultiple < HanamiTest::Action 7 | # format :multipart 8 | 9 | def handle(_request, response) 10 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjPQBoAAAAASUVORK5CYII=' 11 | .unpack('m').first 12 | 13 | response.format = :png 14 | response.body = png 15 | response.headers.merge!( 16 | { 17 | 'Content-Type' => 'image/png', 18 | 'Content-Disposition' => 'inline', 19 | } 20 | ) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/apps/rails/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, or any plugin's 6 | * vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/apps/rails/app/javascript/packs/application.js: -------------------------------------------------------------------------------- 1 | // This file is automatically compiled by Webpack, along with any other files 2 | // present in this directory. You're encouraged to place your actual application logic in 3 | // a relevant structure within app/javascript and only use these pack files to reference 4 | // that code so it'll be compiled. 5 | 6 | require("@rails/ujs").start() 7 | require("@rails/activestorage").start() 8 | require("channels") 9 | 10 | 11 | // Uncomment to copy all static images under ../images to the output folder and reference 12 | // them with the image_pack_tag helper in views (e.g <%= image_pack_tag 'rails.png' %>) 13 | // or the `imagePath` JavaScript helper below. 14 | // 15 | // const images = require.context('../images', true) 16 | // const imagePath = (name) => images(name, true) 17 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/nested_arrays.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class NestedArrays < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => [ 12 | { 13 | "id" => 1, 14 | "tags" => ["ruby", "rails"] 15 | }, 16 | { 17 | "id" => 2, 18 | "tags" => ["python", "django"] 19 | }, 20 | { 21 | "id" => 3, 22 | "tags" => ["javascript"] 23 | } 24 | ] 25 | }.to_json 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/nullable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class Nullable < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = {"users" => [ 11 | { 12 | "label" => "John Doe", 13 | "value" => "john_doe", 14 | "admin" => true 15 | }, 16 | { 17 | "label" => "Jane Doe", 18 | "value" => "jane_doe" 19 | }, 20 | { 21 | "label" => nil, 22 | "value" => "unknown", 23 | "invited" => true 24 | }, 25 | ]}.to_json 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/users/user_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Users 6 | module UserRepository 7 | class RecordNotFound < StandardError; end 8 | 9 | def find_user(id = nil) 10 | case id 11 | when '1', nil 12 | { 13 | name: 'John Doe', 14 | relations: { 15 | avatar: { 16 | url: 'https://example.com/avatar.jpg', 17 | }, 18 | pets: [ 19 | { name: 'doge', age: 8 }, 20 | ], 21 | }, 22 | } 23 | when '2' 24 | {} 25 | else 26 | raise RecordNotFound 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/table_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | class TableAction < HanamiTest::Action 7 | APIKEY = 'k0kubun'.freeze 8 | 9 | include TableRepository 10 | 11 | handle_exception RecordNotFound => :handle_not_fount_error 12 | 13 | before :authenticate 14 | 15 | private 16 | 17 | def handle_not_fount_error(_request, _response, _exception) 18 | halt 404, { message: 'not found' }.to_json 19 | end 20 | 21 | def authenticate(request, response) 22 | return unless request.get_header('AUTHORIZATION') != APIKEY 23 | 24 | response.format = :json 25 | halt 401, { message: 'Unauthorized' }.to_json 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/images_controller.rb: -------------------------------------------------------------------------------- 1 | class ImagesController < ApplicationController 2 | def show 3 | send_image 4 | end 5 | 6 | def index 7 | list = [ 8 | { 9 | 'name': 'file.png', 10 | 'tags': [], # Keep this empty to check empty array is accepted 11 | }, 12 | ] 13 | render json: list 14 | end 15 | 16 | def upload 17 | send_image 18 | end 19 | 20 | def upload_nested 21 | send_image 22 | end 23 | 24 | def upload_multiple 25 | send_image 26 | end 27 | 28 | def upload_multiple_nested 29 | send_image 30 | end 31 | 32 | private 33 | 34 | def send_image 35 | png = 'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAAAAADhZOFXAAAADklEQVQIW2P4DwUMlDEA98A/wTjP 36 | QBoAAAAASUVORK5CYII='.unpack('m').first 37 | send_data png, type: 'image/png', disposition: 'inline' 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: "Rubocop" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v6 18 | 19 | - name: Set up Ruby 20 | uses: ruby/setup-ruby@v1 21 | with: 22 | ruby-version: 3.3 23 | bundler-cache: true 24 | 25 | - name: Rubocop run 26 | run: | 27 | bash -c " 28 | bundle exec rubocop --require code_scanning --format CodeScanning::SarifFormatter -o rubocop.sarif 29 | [[ $? -ne 2 ]] 30 | " 31 | 32 | - name: Upload Sarif output 33 | uses: github/codeql-action/upload-sarif@v4 34 | with: 35 | sarif_file: rubocop.sarif 36 | -------------------------------------------------------------------------------- /spec/integration_tests/roda_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../apps/roda/roda_app' 4 | require 'json' 5 | require 'rack/test' 6 | require 'minitest/autorun' 7 | require 'rspec/openapi' 8 | 9 | ENV['OPENAPI_OUTPUT'] ||= 'yaml' 10 | 11 | RSpec::OpenAPI.title = 'OpenAPI Documentation' 12 | RSpec::OpenAPI.path = File.expand_path("../apps/roda/doc/minitest_openapi.#{ENV.fetch('OPENAPI_OUTPUT', nil)}", __dir__) 13 | RSpec::OpenAPI.ignored_paths = ['/admin/masters/extensions'] 14 | 15 | class RodaTest < Minitest::Test 16 | include Rack::Test::Methods 17 | 18 | i_suck_and_my_tests_are_order_dependent! 19 | openapi! 20 | 21 | def app 22 | RodaApp 23 | end 24 | 25 | def test_when_id_is_given_it_returns_200 26 | post '/roda', { id: 1 }.to_json, 'CONTENT_TYPE' => 'application/json' 27 | assert_equal 200, last_response.status 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in rspec-openapi.gemspec 6 | gemspec 7 | 8 | gem 'rails', ENV['RAILS_VERSION'] || '6.0.6.1' 9 | 10 | if Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0.0') 11 | gem 'hanami', ENV['HANAMI_VERSION'] || '2.1.0' 12 | gem 'hanami-controller', ENV['HANAMI_VERSION'] || '2.1.0' 13 | gem 'hanami-router', ENV['HANAMI_VERSION'] || '2.1.0' 14 | 15 | gem 'dry-logger', '1.0.3' 16 | end 17 | 18 | gem 'concurrent-ruby', '1.3.4' 19 | 20 | gem 'roda' 21 | 22 | gem 'rails-dom-testing', '~> 2.2' 23 | gem 'rspec-rails' 24 | 25 | group :test do 26 | gem 'simplecov', git: 'https://github.com/exoego/simplecov.git', branch: 'branch-fix' 27 | gem 'simplecov-cobertura' 28 | gem 'super_diff' 29 | end 30 | 31 | group :development do 32 | gem 'code-scanning-rubocop' 33 | gem 'pry' 34 | end 35 | -------------------------------------------------------------------------------- /spec/apps/rails/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | /db/*.sqlite3-* 14 | 15 | # Ignore all logfiles and tempfiles. 16 | /log/.keep 17 | /tmp/* 18 | !/log/.keep 19 | !/tmp/.keep 20 | 21 | # Ignore pidfiles, but keep the directory. 22 | /tmp/pids/* 23 | !/tmp/pids/ 24 | !/tmp/pids/.keep 25 | 26 | # Ignore uploaded files in development. 27 | /storage/* 28 | !/storage/.keep 29 | 30 | /public/assets 31 | .byebug_history 32 | 33 | # Ignore master key for decrypting credentials and more. 34 | /config/master.key 35 | -------------------------------------------------------------------------------- /lib/rspec/openapi/rspec_hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/core' 4 | 5 | RSpec.configuration.after(:each) do |example| 6 | if RSpec::OpenAPI.example_types.include?(example.metadata[:type]) && example.metadata[:openapi] != false 7 | path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } 8 | record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor) 9 | RSpec::OpenAPI.path_records[path] << record if record 10 | end 11 | end 12 | 13 | RSpec.configuration.after(:suite) do 14 | result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records) 15 | result_recorder.record_results! 16 | if result_recorder.errors? 17 | error_message = result_recorder.error_message 18 | colorizer = RSpec::Core::Formatters::ConsoleCodes 19 | RSpec.configuration.reporter.message colorizer.wrap(error_message, :failure) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ "master" ] 9 | schedule: 10 | - cron: '20 22 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'ruby' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v6 29 | 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v4 32 | with: 33 | languages: ${{ matrix.language }} 34 | 35 | - name: Autobuild 36 | uses: github/codeql-action/autobuild@v4 37 | 38 | - name: Perform CodeQL Analysis 39 | uses: github/codeql-action/analyze@v4 40 | -------------------------------------------------------------------------------- /spec/apps/rails/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | def create 3 | res = { 4 | name: params[:name] || 'alice', 5 | relations: { 6 | avatar: { 7 | url: params[:avatar_url] || 'https://example.com/avatar.png', 8 | }, 9 | pets: params[:pets] || [], 10 | }, 11 | } 12 | render json: res, status: 201 13 | end 14 | 15 | def show 16 | render json: find_user(params[:id]) 17 | end 18 | 19 | def active 20 | render json: find_user(params[:id]).present? 21 | end 22 | 23 | private 24 | 25 | def find_user(id = nil) 26 | case id 27 | when '1', nil 28 | { 29 | name: 'John Doe', 30 | relations: { 31 | avatar: { 32 | url: 'https://example.com/avatar.jpg', 33 | }, 34 | pets: [ 35 | { name: 'doge', age: 8 }, 36 | ], 37 | }, 38 | } 39 | when '2' 40 | {} 41 | else 42 | raise NotFoundError 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/apps/roda/doc/minitest_openapi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: 3.0.3 3 | info: 4 | title: OpenAPI Documentation 5 | version: 7.7.7 6 | servers: [] 7 | paths: 8 | "/roda": 9 | post: 10 | summary: POST /roda 11 | requestBody: 12 | content: 13 | application/json: 14 | schema: 15 | type: object 16 | properties: 17 | id: 18 | type: integer 19 | required: 20 | - id 21 | example: 22 | id: 1 23 | responses: 24 | '200': 25 | description: when id is given it returns 200 26 | content: 27 | application/json: 28 | schema: 29 | type: object 30 | properties: 31 | id: 32 | type: integer 33 | name: 34 | type: string 35 | required: 36 | - id 37 | - name 38 | example: 39 | id: 1 40 | name: hello 41 | -------------------------------------------------------------------------------- /lib/rspec/openapi/schema_sorter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class << RSpec::OpenAPI::SchemaSorter = Object.new 4 | # Sort some unpredictably ordered properties in a lexicographical manner to make the order more predictable. 5 | # 6 | # @param [Hash|Array] 7 | def deep_sort!(spec) 8 | # paths 9 | deep_sort_by_selector!(spec, 'paths') 10 | 11 | # methods 12 | deep_sort_by_selector!(spec, 'paths.*') 13 | 14 | # response status code 15 | deep_sort_by_selector!(spec, 'paths.*.*.responses') 16 | 17 | # content-type 18 | deep_sort_by_selector!(spec, 'paths.*.*.responses.*.content') 19 | end 20 | 21 | private 22 | 23 | # @param [Hash] base 24 | # @param [String] selector 25 | def deep_sort_by_selector!(base, selector) 26 | RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths| 27 | deep_sort_hash!(base.dig(*paths)) 28 | end 29 | end 30 | 31 | def deep_sort_hash!(hash) 32 | sorted = hash.entries.sort_by { |k, _| k.to_s }.to_h.transform_keys(&:to_sym) 33 | hash.replace(sorted) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/apps/roda/doc/rspec_openapi.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | openapi: 3.0.3 3 | info: 4 | title: OpenAPI Documentation 5 | version: 7.7.7 6 | servers: [] 7 | paths: 8 | "/roda": 9 | post: 10 | summary: Create roda resource 11 | requestBody: 12 | content: 13 | application/json: 14 | schema: 15 | type: object 16 | properties: 17 | id: 18 | type: integer 19 | required: 20 | - id 21 | example: 22 | id: 1 23 | responses: 24 | '200': 25 | description: when id is given it returns 200 26 | content: 27 | application/json: 28 | schema: 29 | type: object 30 | properties: 31 | id: 32 | type: integer 33 | name: 34 | type: string 35 | required: 36 | - id 37 | - name 38 | example: 39 | id: 1 40 | name: hello 41 | -------------------------------------------------------------------------------- /spec/requests/roda_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative '../apps/roda/roda_app' 4 | require 'json' 5 | require 'rack/test' 6 | 7 | ENV['OPENAPI_OUTPUT'] ||= 'yaml' 8 | 9 | RSpec::OpenAPI.title = 'OpenAPI Documentation' 10 | RSpec::OpenAPI.path = File.expand_path("../apps/roda/doc/rspec_openapi.#{ENV.fetch('OPENAPI_OUTPUT', nil)}", __dir__) 11 | RSpec::OpenAPI.ignored_paths = ['/admin/masters/extensions'] 12 | 13 | RSpec::OpenAPI.description_builder = lambda do |example| 14 | contexts = example.example_group.parent_groups.map(&:description).grep(/\Awhen /) 15 | [*contexts, 'it', example.description].join(' ') 16 | end 17 | 18 | RSpec.describe 'Roda', type: :request do 19 | include Rack::Test::Methods 20 | 21 | let(:app) do 22 | RodaApp 23 | end 24 | 25 | describe '/roda', openapi: { summary: 'Create roda resource' } do 26 | context 'when id is given' do 27 | it 'returns 200' do 28 | post '/roda', { id: 1 }.to_json, 'CONTENT_TYPE' => 'application/json' 29 | expect(last_response.status).to eq(200) 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Takashi Kokubun 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 | -------------------------------------------------------------------------------- /spec/rspec/rack_test_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'fileutils' 5 | require 'yaml' 6 | 7 | RSpec.describe 'rack-test spec' do 8 | include SpecHelper 9 | 10 | describe 'yaml output' do 11 | let(:openapi_path) do 12 | File.expand_path('spec/apps/roda/doc/rspec_openapi.yaml', repo_root) 13 | end 14 | 15 | it 'generates the same spec/apps/roda/doc/rspec_openapi.yaml' do 16 | org_yaml = YAML.safe_load(File.read(openapi_path)) 17 | rspec 'spec/requests/roda_spec.rb', openapi: true 18 | new_yaml = YAML.safe_load(File.read(openapi_path)) 19 | expect(new_yaml).to eq org_yaml 20 | end 21 | end 22 | 23 | describe 'json output' do 24 | let(:openapi_path) do 25 | File.expand_path('spec/apps/roda/doc/rspec_openapi.json', repo_root) 26 | end 27 | 28 | it 'generates the same spec/apps/roda/doc/rspec_openapi.json' do 29 | org_yaml = YAML.safe_load(File.read(openapi_path)) 30 | rspec 'spec/requests/roda_spec.rb', openapi: true, output: :json 31 | new_yaml = YAML.safe_load(File.read(openapi_path)) 32 | expect(new_yaml).to eq org_yaml 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/apps/rails/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to setup or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at anytime and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # Install JavaScript dependencies 21 | # system('bin/yarn') 22 | 23 | # puts "\n== Copying sample files ==" 24 | # unless File.exist?('config/database.yml') 25 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 26 | # end 27 | 28 | puts "\n== Preparing database ==" 29 | system! 'bin/rails db:prepare' 30 | 31 | puts "\n== Removing old logs and tempfiles ==" 32 | system! 'bin/rails log:clear tmp:clear' 33 | 34 | puts "\n== Restarting application server ==" 35 | system! 'bin/rails restart' 36 | end 37 | -------------------------------------------------------------------------------- /spec/apps/rails/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /spec/rspec/hanami_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | return unless Gem::Version.new(RUBY_VERSION) >= Gem::Version.new('3.0.0') 4 | 5 | require 'spec_helper' 6 | require 'yaml' 7 | require 'json' 8 | require 'pry' 9 | 10 | RSpec.describe 'hanami request spec' do 11 | include SpecHelper 12 | 13 | describe 'yaml output' do 14 | let(:openapi_path) do 15 | File.expand_path('spec/apps/hanami/doc/openapi.yaml', repo_root) 16 | end 17 | 18 | it 'generates the same spec/apps/hanami/doc/openapi.yaml' do 19 | org_yaml = YAML.safe_load(File.read(openapi_path)) 20 | rspec 'spec/requests/hanami_spec.rb', openapi: true, output: :yaml 21 | new_yaml = YAML.safe_load(File.read(openapi_path)) 22 | expect(new_yaml).to eq org_yaml 23 | end 24 | end 25 | 26 | describe 'json' do 27 | let(:openapi_path) do 28 | File.expand_path('spec/apps/hanami/doc/openapi.json', repo_root) 29 | end 30 | 31 | it 'generates the same spec/apps/hanami/doc/openapi.json' do 32 | org_json = JSON.parse(File.read(openapi_path)) 33 | rspec 'spec/requests/hanami_spec.rb', openapi: true, output: :json 34 | new_json = JSON.parse(File.read(openapi_path)) 35 | expect(new_json).to eq org_json 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/requests/rspec_hook_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['TZ'] ||= 'UTC' 4 | ENV['RAILS_ENV'] ||= 'test' 5 | ENV['OPENAPI_OUTPUT'] ||= 'yaml' 6 | 7 | require File.expand_path('../apps/rails/config/environment', __dir__) 8 | require 'rspec/rails' 9 | require 'fileutils' 10 | 11 | openapi_path = ENV.fetch('RSPEC_HOOK_OPENAPI_PATH') do 12 | File.expand_path('../apps/rails/tmp/rspec_hook_error.yaml', __dir__) 13 | end 14 | FileUtils.mkdir_p(File.dirname(openapi_path)) 15 | FileUtils.rm_f(openapi_path) 16 | 17 | RSpec::OpenAPI.title = 'OpenAPI Documentation' 18 | RSpec::OpenAPI.path = openapi_path 19 | RSpec::OpenAPI.request_headers = [] 20 | RSpec::OpenAPI.response_headers = [] 21 | RSpec::OpenAPI.path_records.clear 22 | 23 | RSpec.describe 'RSpec hooks error handling', type: :request do 24 | after(:context) do 25 | path = RSpec::OpenAPI.path 26 | record = RSpec::OpenAPI.path_records[path].last 27 | raise 'OpenAPI record was not generated' unless record 28 | 29 | invalid_record = RSpec::OpenAPI::Record.new(**record.to_h.merge(response_body: Object.new)) 30 | RSpec::OpenAPI.path_records[path] << invalid_record 31 | end 32 | 33 | it 'produces reporter output when schema building fails' do 34 | get '/invalid_responses' 35 | expect(response).to have_http_status(:ok) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/apps/rails/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | # require "active_model/railtie" 6 | # require "active_job/railtie" 7 | # require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | require "action_view/railtie" 14 | # require "action_cable/engine" 15 | # require "sprockets/railtie" 16 | # require "rails/test_unit/railtie" 17 | 18 | require_relative "../vendor/my_engine/lib/my_engine/engine" 19 | 20 | # Require the gems listed in Gemfile, including any gems 21 | # you've limited to :test, :development, or :production. 22 | Bundler.require(*Rails.groups) 23 | 24 | module Railsapp 25 | class Application < Rails::Application 26 | # Initialize configuration defaults for originally generated Rails version. 27 | config.load_defaults 6.0 28 | 29 | # Settings in config/environments/* take precedence over those specified here. 30 | # Application configuration can go into files in config/initializers 31 | # -- all .rb files in that directory are automatically loaded after loading 32 | # the framework and any gems in your application. 33 | 34 | # Don't generate system test files. 35 | config.generators.system_tests = nil 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/minitest/rack_test_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'fileutils' 5 | require 'yaml' 6 | 7 | RSpec.describe 'rack-test minitest' do 8 | include SpecHelper 9 | 10 | describe 'yaml output' do 11 | let(:openapi_path) do 12 | File.expand_path('spec/apps/roda/doc/minitest_openapi.yaml', repo_root) 13 | end 14 | 15 | it 'generates the same spec/apps/roda/doc/minitest_openapi.yaml' do 16 | org_yaml = YAML.safe_load(File.read(openapi_path)) 17 | minitest 'spec/integration_tests/roda_test.rb', openapi: true 18 | new_yaml = YAML.safe_load(File.read(openapi_path)) 19 | expect(new_yaml).to eq org_yaml 20 | end 21 | end 22 | 23 | describe 'json output' do 24 | let(:openapi_path) do 25 | File.expand_path('spec/apps/roda/doc/minitest_openapi.json', repo_root) 26 | end 27 | 28 | it 'generates the same spec/apps/roda/doc/minitest_openapi.json' do 29 | org_yaml = YAML.safe_load(File.read(openapi_path)) 30 | minitest 'spec/integration_tests/roda_test.rb', openapi: true, output: :json 31 | new_yaml = YAML.safe_load(File.read(openapi_path)) 32 | expect(new_yaml).to eq org_yaml 33 | end 34 | end 35 | 36 | describe 'with disabled OpenAPI generation' do 37 | it 'can run tests' do 38 | minitest 'spec/integration_tests/roda_test.rb' 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/rspec/schema_file_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe RSpec::OpenAPI::SchemaFile do 6 | describe '#read' do 7 | let(:schema_content) do 8 | <<~YAML 9 | openapi: 3.0.0 10 | info: 11 | title: My API 12 | version: 1.0.0 13 | paths: 14 | /: 15 | get: 16 | summary: A test endpoint 17 | parameters: 18 | - name: date 19 | in: query 20 | schema: 21 | type: string 22 | date: 2020-01-02 # Unquoted date 23 | time: 2025-06-10 01:47:28Z 24 | YAML 25 | end 26 | 27 | it 'deserializes unquoted dates as Date objects when Date is permitted' do 28 | schema_file = RSpec::OpenAPI::SchemaFile.new('nonexistant/schema.yaml') 29 | 30 | expect(File).to receive(:read).and_return(schema_content) 31 | expect(File).to receive(:exist?).and_return(true) 32 | 33 | data = nil 34 | expect do 35 | data = schema_file.send(:read) 36 | end.not_to raise_error(Psych::DisallowedClass) 37 | expect(data.dig(:paths, :/, :get, :parameters, 0, :schema, :date).to_s).to eq('2020-01-02') 38 | expect(data.dig(:paths, :/, :get, :parameters, 0, :schema, :time).to_s).to eq('2025-06-10 01:47:28 UTC') 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to RubyGems 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | name: Publish gem and GitHub release 11 | runs-on: ubuntu-latest 12 | 13 | permissions: 14 | id-token: write # for RubyGems trusted publishing 15 | contents: write # to create GitHub release 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Set up Ruby 23 | uses: ruby/setup-ruby@v1 24 | with: 25 | bundler-cache: true 26 | ruby-version: ruby 27 | 28 | - name: Verify tag matches version.rb 29 | run: | 30 | set -euo pipefail 31 | tag="${GITHUB_REF_NAME}" 32 | version="${tag#v}" 33 | file_version=$(ruby -e "require_relative './lib/rspec/openapi/version'; puts RSpec::OpenAPI::VERSION") 34 | if [ "$version" != "$file_version" ]; then 35 | echo "Tag version ($version) does not match lib/rspec/openapi/version.rb ($file_version)" >&2 36 | exit 1 37 | fi 38 | 39 | - uses: rubygems/release-gem@v1 40 | 41 | - name: Create GitHub release 42 | uses: softprops/action-gh-release@v2 43 | with: 44 | tag_name: ${{ github.ref_name }} 45 | name: ${{ github.ref_name }} 46 | generate_release_notes: true 47 | -------------------------------------------------------------------------------- /spec/minitest/rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'yaml' 5 | require 'json' 6 | require 'pry' 7 | 8 | RSpec.describe 'rails integration minitest' do 9 | include SpecHelper 10 | 11 | describe 'yaml output' do 12 | let(:openapi_path) do 13 | File.expand_path('spec/apps/rails/doc/minitest_openapi.yaml', repo_root) 14 | end 15 | 16 | it 'generates the same spec/apps/rails/doc/minitest_openapi.yaml' do 17 | org_yaml = YAML.safe_load(File.read(openapi_path)) 18 | minitest 'spec/integration_tests/rails_test.rb', openapi: true, output: :yaml 19 | new_yaml = YAML.safe_load(File.read(openapi_path)) 20 | expect(new_yaml).to eq org_yaml 21 | end 22 | end 23 | 24 | describe 'json' do 25 | let(:openapi_path) do 26 | File.expand_path('spec/apps/rails/doc/minitest_openapi.json', repo_root) 27 | end 28 | 29 | it 'generates the same spec/apps/rails/doc/minitest_openapi.json' do 30 | org_yaml = JSON.parse(File.read(openapi_path)) 31 | minitest 'spec/integration_tests/rails_test.rb', openapi: true, output: :json 32 | new_yaml = JSON.parse(File.read(openapi_path)) 33 | expect(new_yaml).to eq org_yaml 34 | end 35 | end 36 | 37 | describe 'with disabled OpenAPI generation' do 38 | it 'can run tests' do 39 | minitest 'spec/integration_tests/rails_test.rb' 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/rspec/scheme_merger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe 'schema merger spec' do 6 | include SpecHelper 7 | 8 | describe 'mixed symbol and strings' do 9 | let(:base) do 10 | { 11 | 'n' => 1, 12 | 'required' => %w[foo bar], 13 | 'a' => { 14 | b1: 1, 15 | b2: %w[foo bar], 16 | 'b3' => { 17 | 'c1' => 2, 18 | c2: 3, 19 | }, 20 | }, 21 | } 22 | end 23 | 24 | let(:spec) do 25 | { 26 | n: 1, 27 | required: ['buz'], 28 | a: { 29 | 'b1' => 1, 30 | 'b2' => %w[foo bar], 31 | b3: { 32 | c1: 2, 33 | 'c2' => 3, 34 | }, 35 | }, 36 | } 37 | end 38 | 39 | it 'normalize keys to symbol' do 40 | result = RSpec::OpenAPI::SchemaMerger.merge!(base, spec) 41 | expect(result).to eq({ 42 | n: 1, 43 | required: [], 44 | a: { 45 | b1: 1, 46 | b2: %w[foo bar], 47 | b3: { 48 | c1: 2, 49 | c2: 3, 50 | }, 51 | }, 52 | }) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/rspec/openapi/schema_file.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'fileutils' 4 | require 'yaml' 5 | require 'json' 6 | 7 | # For Ruby 2.7 8 | require 'date' 9 | 10 | # TODO: Support JSON 11 | class RSpec::OpenAPI::SchemaFile 12 | # @param [String] path 13 | def initialize(path) 14 | @path = path 15 | end 16 | 17 | def edit(&block) 18 | spec = read 19 | block.call(spec) 20 | ensure 21 | write(RSpec::OpenAPI::KeyTransformer.stringify(spec)) 22 | end 23 | 24 | private 25 | 26 | # @return [Hash] 27 | def read 28 | return {} unless File.exist?(@path) 29 | 30 | RSpec::OpenAPI::KeyTransformer.symbolize( 31 | YAML.safe_load( 32 | File.read(@path), 33 | permitted_classes: [Date, Time], 34 | ), 35 | ) # this can also parse JSON 36 | end 37 | 38 | # @param [Hash] spec 39 | def write(spec) 40 | FileUtils.mkdir_p(File.dirname(@path)) 41 | output = 42 | if json? 43 | JSON.pretty_generate(spec) 44 | else 45 | prepend_comment(YAML.dump(spec)) 46 | end 47 | File.write(@path, output) 48 | end 49 | 50 | def prepend_comment(content) 51 | return content if RSpec::OpenAPI.comment.nil? 52 | 53 | comment = RSpec::OpenAPI.comment.dup 54 | comment << "\n" unless comment.end_with?("\n") 55 | "#{comment.gsub(/^/, '# ').gsub(/^# \n/, "#\n")}#{content}" 56 | end 57 | 58 | def json? 59 | File.extname(@path) == '.json' 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | types: 8 | - opened 9 | - synchronize 10 | - reopened 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | container: ${{ matrix.ruby }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - ruby: ruby:2.7 20 | - ruby: ruby:3.0 21 | - ruby: ruby:3.1 22 | - ruby: ruby:3.1 23 | rails: 6.1.7 24 | - ruby: ruby:3.1 25 | rails: 7.0.8 26 | - ruby: ruby:3.3 27 | rails: 7.1.3.2 28 | - ruby: ruby:3.4 29 | rails: 8.0.2 30 | coverage: coverage 31 | env: 32 | RAILS_VERSION: ${{ matrix.rails == '' && '6.1.6' || matrix.rails }} 33 | COVERAGE: ${{ matrix.coverage || '' }} 34 | steps: 35 | - uses: actions/checkout@v6 36 | - name: bundle install 37 | run: bundle install -j$(nproc) --retry 3 38 | - run: bundle exec rspec 39 | timeout-minutes: 1 40 | - run: git config --global --add safe.directory "$GITHUB_WORKSPACE" 41 | name: codecov-action@v4 workaround 42 | - name: Upload coverage reports 43 | uses: codecov/codecov-action@v5 44 | if: matrix.coverage == 'coverage' 45 | with: 46 | fail_ci_if_error: true 47 | files: ./coverage/coverage.xml 48 | token: ${{ secrets.CODECOV_TOKEN }} 49 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/nested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class Nested < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "fields" => [ 12 | { 13 | "id" => "country_code", 14 | "options" => [ 15 | { 16 | "id" => "us", 17 | "label" => "United States" 18 | }, 19 | { 20 | "id" => "ca", 21 | "label" => "Canada" 22 | } 23 | ], 24 | "validations" => nil, 25 | "always_nil" => nil 26 | }, 27 | { 28 | "id" => "region_id", 29 | "options" => [ 30 | { 31 | "id" => 1, 32 | "label" => "New York" 33 | }, 34 | { 35 | "id" => 2, 36 | "label" => "California" 37 | } 38 | ], 39 | "validations" => { 40 | "presence" => true 41 | }, 42 | "always_nil" => nil 43 | } 44 | ] 45 | }.to_json 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/apps/hanami/config/puma.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # 4 | # Environment and port 5 | # 6 | port ENV.fetch('HANAMI_PORT', 2300) 7 | environment ENV.fetch('HANAMI_ENV', 'development') 8 | 9 | # 10 | # Threads within each Puma/Ruby process (aka worker) 11 | # 12 | 13 | # Configure the minimum and maximum number of threads to use to answer requests. 14 | max_threads_count = ENV.fetch('HANAMI_MAX_THREADS', 5) 15 | min_threads_count = ENV.fetch('HANAMI_MIN_THREADS') { max_threads_count } 16 | 17 | threads min_threads_count, max_threads_count 18 | 19 | # 20 | # Workers (aka Puma/Ruby processes) 21 | # 22 | 23 | puma_concurrency = Integer(ENV.fetch('HANAMI_WEB_CONCURRENCY', 0)) 24 | puma_cluster_mode = puma_concurrency > 1 25 | 26 | # How many worker (Puma/Ruby) processes to run. 27 | # Typically this is set to the number of available cores. 28 | workers puma_concurrency 29 | 30 | # 31 | # Cluster mode (aka multiple workers) 32 | # 33 | 34 | if puma_cluster_mode 35 | # Preload the application before starting the workers. Only in cluster mode. 36 | preload_app! 37 | 38 | # Code to run immediately before master process forks workers (once on boot). 39 | # 40 | # These hooks can block if necessary to wait for background operations unknown 41 | # to puma to finish before the process terminates. This can be used to close 42 | # any connections to remote servers (database, redis, …) that were opened when 43 | # preloading the code. 44 | before_fork do 45 | Hanami.shutdown 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/rspec/openapi/hash_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class << RSpec::OpenAPI::HashHelper = Object.new 4 | def paths_to_all_fields(obj) 5 | case obj 6 | when Hash 7 | obj.each.flat_map do |k, v| 8 | k = k.to_sym 9 | [[k]] + paths_to_all_fields(v).map { |x| [k, *x] } 10 | end 11 | when Array 12 | obj.flat_map.with_index do |value, i| 13 | [[i]] + paths_to_all_fields(value).map { |x| [i, *x] } 14 | end 15 | else 16 | [] 17 | end 18 | end 19 | 20 | def matched_paths(obj, selector) 21 | selector_parts = selector.split('.').map(&:to_sym) 22 | paths_to_all_fields(obj).select do |key_parts| 23 | key_parts.size == selector_parts.size && key_parts.zip(selector_parts).all? do |kp, sp| 24 | kp == sp || (sp == :* && !kp.nil?) 25 | end 26 | end 27 | end 28 | 29 | def matched_paths_deeply_nested(obj, begin_selector, end_selector) 30 | path_depth_sizes = paths_to_all_fields(obj).map(&:size).uniq 31 | path_depth_sizes.map do |depth| 32 | begin_selector_count = begin_selector.is_a?(Symbol) ? 0 : begin_selector.count('.') 33 | end_selector_count = end_selector.is_a?(Symbol) ? 0 : end_selector.count('.') 34 | diff = depth - begin_selector_count - end_selector_count 35 | if diff >= 0 36 | selector = "#{begin_selector}.#{'*.' * diff}#{end_selector}" 37 | matched_paths(obj, selector) 38 | else 39 | [] 40 | end 41 | end.flatten(1) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /rspec-openapi.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/rspec/openapi/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'rspec-openapi' 7 | spec.version = RSpec::OpenAPI::VERSION 8 | spec.authors = ['Takashi Kokubun', 'TATSUNO Yasuhiro'] 9 | spec.email = ['takashikkbn@gmail.com', 'ytatsuno.jp@gmail.com'] 10 | 11 | spec.summary = 'Generate OpenAPI schema from RSpec request specs' 12 | spec.description = 'Generate OpenAPI from RSpec request specs' 13 | spec.homepage = 'https://github.com/exoego/rspec-openapi' 14 | spec.license = 'MIT' 15 | spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0') 16 | 17 | spec.metadata = { 18 | 'homepage_uri' => 'https://github.com/exoego/rspec-openapi', 19 | 'source_code_uri' => 'https://github.com/exoego/rspec-openapi', 20 | 'changelog_uri' => "https://github.com/exoego/rspec-openapi/releases/tag/v#{RSpec::OpenAPI::VERSION}", 21 | 'rubygems_mfa_required' => 'true', 22 | } 23 | 24 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 25 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 26 | end 27 | spec.bindir = 'exe' 28 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 29 | spec.require_paths = ['lib'] 30 | 31 | spec.add_dependency 'actionpack', '>= 5.2.0' 32 | spec.add_dependency 'rails-dom-testing' 33 | spec.add_dependency 'rspec-core' 34 | spec.metadata['rubygems_mfa_required'] = 'true' 35 | end 36 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/tables/table_repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module Tables 6 | module TableRepository 7 | class RecordNotFound < StandardError; end 8 | 9 | def find_table(id = nil) 10 | time = Time.parse('2020-07-17 00:00:00') 11 | case id 12 | when '1', nil 13 | { 14 | id: 1, 15 | name: 'access', 16 | description: 'logs', 17 | database: { 18 | id: 2, 19 | name: 'production', 20 | }, 21 | null_sample: nil, 22 | storage_size: 12.3, 23 | created_at: time.iso8601, 24 | updated_at: time.iso8601, 25 | } 26 | when '42' 27 | { 28 | id: 42, 29 | name: 'access', 30 | description: 'logs', 31 | database: { 32 | id: 4242, 33 | name: 'production', 34 | }, 35 | columns: [ 36 | { name: 'id', column_type: 'integer' }, 37 | { name: 'description', column_type: 'varchar' }, 38 | ], 39 | null_sample: nil, 40 | storage_size: 12.3, 41 | created_at: time.iso8601, 42 | updated_at: time.iso8601, 43 | } 44 | else 45 | raise RecordNotFound 46 | end 47 | end 48 | end 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/apps/rails/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy 4 | # For further information see the following documentation 5 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy 6 | 7 | # Rails.application.config.content_security_policy do |policy| 8 | # policy.default_src :self, :https 9 | # policy.font_src :self, :https, :data 10 | # policy.img_src :self, :https, :data 11 | # policy.object_src :none 12 | # policy.script_src :self, :https 13 | # policy.style_src :self, :https 14 | # # If you are using webpack-dev-server then specify webpack-dev-server host 15 | # policy.connect_src :self, :https, "http://localhost:3035", "ws://localhost:3035" if Rails.env.development? 16 | 17 | # # Specify URI for violation reports 18 | # # policy.report_uri "/csp-violation-report-endpoint" 19 | # end 20 | 21 | # If you are using UJS then enable automatic nonce generation 22 | # Rails.application.config.content_security_policy_nonce_generator = -> request { SecureRandom.base64(16) } 23 | 24 | # Set the nonce only to specific directives 25 | # Rails.application.config.content_security_policy_nonce_directives = %w(script-src) 26 | 27 | # Report CSP violations to a specified URI 28 | # For further information see the following documentation: 29 | # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy-Report-Only 30 | # Rails.application.config.content_security_policy_report_only = true 31 | -------------------------------------------------------------------------------- /lib/rspec/openapi/minitest_hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'minitest' 4 | 5 | module RSpec::OpenAPI::Minitest 6 | Example = Struct.new(:context, :description, :metadata, :file_path) 7 | 8 | module RunPatch 9 | def run(*args) 10 | result = super 11 | if ENV['OPENAPI'] && self.class.openapi? 12 | file_path = method(name).source_location.first 13 | human_name = name.sub(/^test_/, '').gsub('_', ' ') 14 | example = Example.new(self, human_name, {}, file_path) 15 | path = RSpec::OpenAPI.path.then { |p| p.is_a?(Proc) ? p.call(example) : p } 16 | record = RSpec::OpenAPI::RecordBuilder.build(self, example: example, extractor: SharedHooks.find_extractor) 17 | RSpec::OpenAPI.path_records[path] << record if record 18 | end 19 | result 20 | end 21 | end 22 | 23 | module ActivateOpenApiClassMethods 24 | def self.prepended(base) 25 | base.extend(ClassMethods) 26 | end 27 | 28 | module ClassMethods 29 | def openapi? 30 | @openapi 31 | end 32 | 33 | def openapi! 34 | @openapi = true 35 | end 36 | end 37 | end 38 | end 39 | 40 | Minitest::Test.prepend RSpec::OpenAPI::Minitest::ActivateOpenApiClassMethods 41 | 42 | if ENV['OPENAPI'] 43 | Minitest::Test.prepend RSpec::OpenAPI::Minitest::RunPatch 44 | 45 | Minitest.after_run do 46 | result_recorder = RSpec::OpenAPI::ResultRecorder.new(RSpec::OpenAPI.path_records) 47 | result_recorder.record_results! 48 | puts result_recorder.error_message if result_recorder.errors? 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open3' 4 | require 'super_diff/rspec' 5 | 6 | module SpecHelper 7 | def repo_root 8 | File.expand_path('..', __dir__) 9 | end 10 | 11 | def assert_run(*args) 12 | out, err, status = Open3.capture3(*args) 13 | expect(status.success?).to eq(true), "stdout:\n#{out}\nstderr:\n#{err}" 14 | end 15 | 16 | def run_tests(*args, command:, openapi: false, output: :yaml) 17 | env = { 18 | 'OPENAPI' => ('1' if openapi), 19 | 'OPENAPI_OUTPUT' => output.to_s, 20 | }.compact 21 | Bundler.public_send(Bundler.respond_to?(:with_unbundled_env) ? :with_unbundled_env : :with_clean_env) do 22 | Dir.chdir(repo_root) do 23 | assert_run env, 'bundle', 'exec', command, '-r./.simplecov_spawn', *args 24 | end 25 | end 26 | end 27 | 28 | def rspec(*args, openapi: false, output: :yaml) 29 | run_tests(*args, command: 'scripts/rspec_with_simplecov', openapi: openapi, output: output) 30 | end 31 | 32 | def minitest(*args, openapi: false, output: :yaml) 33 | run_tests(*args, command: 'ruby', openapi: openapi, output: output) 34 | end 35 | end 36 | 37 | RSpec.configure do |config| 38 | # Enable flags like --only-failures and --next-failure 39 | config.example_status_persistence_file_path = '.rspec_status' 40 | 41 | # Disable RSpec exposing methods globally on `Module` and `main` 42 | config.disable_monkey_patching! 43 | 44 | config.expect_with :rspec do |c| 45 | c.syntax = :expect 46 | end 47 | end 48 | 49 | SuperDiff.configure do |config| 50 | config.diff_elision_enabled = true 51 | end 52 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/mixed_types_nested.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class MixedTypesNested < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => [ 12 | { 13 | "id" => 1, 14 | "config" => { 15 | "port" => 8080, 16 | "host" => "localhost" 17 | }, 18 | "form" => [ 19 | { 20 | "value" => "John Doe", 21 | "options" => [ 22 | {"label" => "John Doe", "value" => "john_doe"}, 23 | {"label" => "Jane Doe", "value" => "jane_doe"} 24 | ] 25 | }, 26 | { 27 | "value" => [], 28 | "options" => { 29 | "endpoint" => "some/endpoint" 30 | } 31 | }, 32 | { 33 | "value" => nil, 34 | "options" => nil 35 | }, 36 | ] 37 | }, 38 | { 39 | "id" => 2, 40 | "config" => { 41 | "port" => "3000", 42 | "host" => "example.com", 43 | "ssl" => true 44 | }, 45 | "form" => nil 46 | } 47 | ] 48 | }.to_json 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /scripts/rspec_with_simplecov: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # (The MIT License) 5 | # Copyright (c) 2012 Chad Humphries, David Chelimsky, Myron Marston 6 | # Copyright (c) 2009 Chad Humphries, David Chelimsky 7 | # Copyright (c) 2006 David Chelimsky, The RSpec Development Team 8 | # Copyright (c) 2005 Steven Baker 9 | 10 | # Turn on verbose to make sure we not generating any ruby warning 11 | $VERBOSE = true 12 | 13 | # So our "did they run the rspec command?" detection logic thinks 14 | # that we run `rspec`. 15 | $0 = 'rspec' 16 | 17 | # This is necessary for when `--standalone` is being used. 18 | $LOAD_PATH.unshift File.expand_path '../bundle', __dir__ 19 | 20 | # For the travis build we put the bundle directory up a directory 21 | # so it can be shared among the repos for faster bundle installs. 22 | $LOAD_PATH.unshift File.expand_path '../../bundle', __dir__ 23 | 24 | require 'bundler/setup' 25 | 26 | # To use simplecov while running rspec-core's test suite, we must 27 | # load simplecov _before_ loading any of rspec-core's files. 28 | # So, this executable exists purely as a wrapper script that 29 | # first loads simplecov, and then loads rspec. 30 | begin 31 | # Simplecov emits some ruby warnings when loaded, so silence them. 32 | old_verbose = $VERBOSE 33 | $VERBOSE = false 34 | 35 | unless (ENV.fetch('COVERAGE', nil) && ENV['COVERAGE'].empty?) || RUBY_VERSION < '1.9.3' 36 | require 'simplecov' 37 | 38 | SimpleCov.start do 39 | enable_coverage :branch 40 | end 41 | end 42 | rescue LoadError 43 | # simplecov is not available 44 | ensure 45 | $VERBOSE = old_verbose 46 | end 47 | 48 | load File.expand_path('rspec', __dir__) 49 | -------------------------------------------------------------------------------- /lib/rspec/openapi/record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::OpenAPI::Record = Struct.new( 4 | :title, # @param [String] - "API Documentation - Statuses" 5 | :http_method, # @param [String] - "GET" 6 | :path, # @param [String] - "/v1/status/:id" 7 | :path_params, # @param [Hash] - {:controller=>"v1/statuses", :action=>"create", :id=>"1"} 8 | :query_params, # @param [Hash] - {:query=>"string"} 9 | :request_params, # @param [Hash] - {:request=>"body"} 10 | :required_request_params, # @param [Array] - ["param1", "param2"] 11 | :request_content_type, # @param [String] - "application/json" 12 | :request_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]] 13 | :summary, # @param [String] - "v1/statuses #show" 14 | :tags, # @param [Array] - ["Status"] 15 | :formats, # @param [Proc] - ->(key) { key.end_with?('_at') ? 'date-time' : nil } 16 | :operation_id, # @param [String] - "request-1234" 17 | :description, # @param [String] - "returns a status" 18 | :security, # @param [Array] - [{securityScheme1: []}] 19 | :deprecated, # @param [Boolean] - true 20 | :status, # @param [Integer] - 200 21 | :response_body, # @param [Object] - {"status" => "ok"} 22 | :response_headers, # @param [Array] - [["header_key1", "header_value1"], ["header_key2", "header_value2"]] 23 | :response_content_type, # @param [String] - "application/json" 24 | :response_content_disposition, # @param [String] - "inline" 25 | keyword_init: true, 26 | ) 27 | -------------------------------------------------------------------------------- /spec/apps/roda/doc/minitest_openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "OpenAPI Documentation", 5 | "version": "7.7.7" 6 | }, 7 | "servers": [], 8 | "paths": { 9 | "/roda": { 10 | "post": { 11 | "summary": "POST /roda", 12 | "requestBody": { 13 | "content": { 14 | "application/json": { 15 | "schema": { 16 | "type": "object", 17 | "properties": { 18 | "id": { 19 | "type": "integer" 20 | } 21 | }, 22 | "required": [ 23 | "id" 24 | ] 25 | }, 26 | "example": { 27 | "id": 1 28 | } 29 | } 30 | } 31 | }, 32 | "responses": { 33 | "200": { 34 | "description": "when id is given it returns 200", 35 | "content": { 36 | "application/json": { 37 | "schema": { 38 | "type": "object", 39 | "properties": { 40 | "id": { 41 | "type": "integer" 42 | }, 43 | "name": { 44 | "type": "string" 45 | } 46 | }, 47 | "required": [ 48 | "id", 49 | "name" 50 | ] 51 | }, 52 | "example": { 53 | "id": 1, 54 | "name": "hello" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /spec/apps/roda/doc/rspec_openapi.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "OpenAPI Documentation", 5 | "version": "7.7.7" 6 | }, 7 | "servers": [], 8 | "paths": { 9 | "/roda": { 10 | "post": { 11 | "summary": "Create roda resource", 12 | "requestBody": { 13 | "content": { 14 | "application/json": { 15 | "schema": { 16 | "type": "object", 17 | "properties": { 18 | "id": { 19 | "type": "integer" 20 | } 21 | }, 22 | "required": [ 23 | "id" 24 | ] 25 | }, 26 | "example": { 27 | "id": 1 28 | } 29 | } 30 | } 31 | }, 32 | "responses": { 33 | "200": { 34 | "description": "when id is given it returns 200", 35 | "content": { 36 | "application/json": { 37 | "schema": { 38 | "type": "object", 39 | "properties": { 40 | "id": { 41 | "type": "integer" 42 | }, 43 | "name": { 44 | "type": "string" 45 | } 46 | }, 47 | "required": [ 48 | "id", 49 | "name" 50 | ] 51 | }, 52 | "example": { 53 | "id": 1, 54 | "name": "hello" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /spec/apps/rails/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 12 | # 13 | port ENV.fetch("PORT") { 3000 } 14 | 15 | # Specifies the `environment` that Puma will run in. 16 | # 17 | environment ENV.fetch("RAILS_ENV") { "development" } 18 | 19 | # Specifies the `pidfile` that Puma will use. 20 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 21 | 22 | # Specifies the number of `workers` to boot in clustered mode. 23 | # Workers are forked web server processes. If using threads and workers together 24 | # the concurrency of the application would be max `threads` * `workers`. 25 | # Workers do not work on JRuby or Windows (both of which do not support 26 | # processes). 27 | # 28 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 29 | 30 | # Use the `preload_app!` method when specifying a `workers` number. 31 | # This directive tells Puma to first boot the application and load code 32 | # before forking the application. This takes advantage of Copy On Write 33 | # process behavior so workers use less memory. 34 | # 35 | # preload_app! 36 | 37 | # Allow puma to be restarted by `rails restart` command. 38 | plugin :tmp_restart 39 | -------------------------------------------------------------------------------- /spec/apps/rails/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount ::MyEngine::Engine => '/my_engine' 3 | mount ::RackTest::App.new, at: '/rack' 4 | 5 | get '/my_engine/test' => ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['ANOTHER TEST']] } 6 | 7 | defaults format: :html do 8 | get '/pages' => 'pages#get' 9 | end 10 | 11 | defaults format: 'json' do 12 | resources :sites, param: :name, only: [:show] 13 | resources :tables, only: [:index, :show, :create, :update, :destroy] 14 | resources :images, only: [:index, :show] do 15 | collection do 16 | post 'upload' 17 | post 'upload_nested' 18 | post 'upload_multiple' 19 | post 'upload_multiple_nested' 20 | end 21 | end 22 | resources :users, only: [:show, :create] do 23 | get 'active' 24 | end 25 | 26 | get '/test_block' => ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['A TEST']] } 27 | 28 | get '/secret_items' => 'secret_items#index' 29 | 30 | get '/additional_properties' => 'additional_properties#index' 31 | get '/invalid_responses' => 'invalid_responses#show' 32 | resources :array_hashes, only: [] do 33 | get :nullable, on: :collection 34 | get :non_nullable, on: :collection 35 | get :nested, on: :collection 36 | get :empty_array, on: :collection 37 | get :single_item, on: :collection 38 | get :non_hash_items, on: :collection 39 | get :nested_arrays, on: :collection 40 | get :nested_objects, on: :collection 41 | get :mixed_types_nested, on: :collection 42 | end 43 | 44 | scope :admin do 45 | namespace :masters do 46 | resources :extensions, only: [:index, :create] 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/apps/rails/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | require 'active_support/time' 6 | 7 | Rails.application.configure do 8 | # Settings specified here will take precedence over those in config/application.rb. 9 | 10 | config.cache_classes = false 11 | config.action_view.cache_template_loading = true 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}", 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | config.action_dispatch.show_exceptions = false 31 | 32 | # Disable request forgery protection in test environment. 33 | config.action_controller.allow_forgery_protection = false 34 | 35 | # Print deprecation notices to the stderr. 36 | config.active_support.deprecation = :stderr 37 | 38 | # Raises error for missing translations. 39 | # config.action_view.raise_on_missing_translations = true 40 | end 41 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2024-04-20 21:25:22 UTC using RuboCop version 1.62.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 14 10 | # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. 11 | Metrics/AbcSize: 12 | Max: 46 13 | 14 | # Offense count: 1 15 | # Configuration parameters: CountComments, CountAsOne. 16 | Metrics/ClassLength: 17 | Max: 195 18 | 19 | # Offense count: 9 20 | # Configuration parameters: AllowedMethods, AllowedPatterns. 21 | Metrics/CyclomaticComplexity: 22 | Max: 13 23 | 24 | # Offense count: 22 25 | # Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. 26 | Metrics/MethodLength: 27 | Max: 36 28 | 29 | # Offense count: 5 30 | # Configuration parameters: AllowedMethods, AllowedPatterns. 31 | Metrics/PerceivedComplexity: 32 | Max: 13 33 | 34 | # Offense count: 1 35 | # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns. 36 | # SupportedStyles: snake_case, normalcase, non_integer 37 | # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339, x86_64 38 | Naming/VariableNumber: 39 | Exclude: 40 | - 'spec/integration_tests/roda_test.rb' 41 | 42 | # Offense count: 7 43 | # Configuration parameters: AllowedConstants. 44 | Style/Documentation: 45 | Exclude: 46 | - 'spec/**/*' 47 | - 'test/**/*' 48 | - 'lib/rspec/openapi.rb' 49 | - 'lib/rspec/openapi/minitest_hooks.rb' 50 | - 'lib/rspec/openapi/result_recorder.rb' 51 | - 'lib/rspec/openapi/schema_file.rb' 52 | - 'lib/rspec/openapi/shared_hooks.rb' 53 | -------------------------------------------------------------------------------- /spec/apps/rails/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /spec/apps/rails/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/apps/rails/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/rspec/rails_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | require 'yaml' 5 | require 'json' 6 | require 'pry' 7 | 8 | RSpec.describe 'rails request spec' do 9 | include SpecHelper 10 | 11 | describe 'yaml output' do 12 | let(:openapi_path) do 13 | File.expand_path('spec/apps/rails/doc/rspec_openapi.yaml', repo_root) 14 | end 15 | 16 | it 'generates the same spec/apps/rails/doc/rspec_openapi.yaml' do 17 | org_yaml = YAML.safe_load(File.read(openapi_path)) 18 | rspec 'spec/requests/rails_spec.rb', openapi: true, output: :yaml 19 | new_yaml = YAML.safe_load(File.read(openapi_path)) 20 | expect(new_yaml).to eq org_yaml 21 | end 22 | end 23 | 24 | describe 'json' do 25 | let(:openapi_path) do 26 | File.expand_path('spec/apps/rails/doc/rspec_openapi.json', repo_root) 27 | end 28 | 29 | it 'generates the same spec/apps/rails/doc/rspec_openapi.json' do 30 | org_json = JSON.parse(File.read(openapi_path)) 31 | rspec 'spec/requests/rails_spec.rb', openapi: true, output: :json 32 | new_json = JSON.parse(File.read(openapi_path)) 33 | expect(new_json).to eq org_json 34 | end 35 | end 36 | 37 | describe 'smart merge' do 38 | let(:openapi_path) do 39 | File.expand_path('spec/apps/rails/doc/smart/openapi.yaml', repo_root) 40 | end 41 | 42 | let(:expected_path) do 43 | File.expand_path('spec/apps/rails/doc/smart/expected.yaml', repo_root) 44 | end 45 | 46 | it 'updates the spec/apps/rails/doc/smart/openapi.yaml as same as in expected.yaml' do 47 | original_source = File.read(openapi_path) 48 | begin 49 | rspec 'spec/requests/rails_smart_merge_spec.rb', openapi: true, output: :yaml 50 | new_yaml = YAML.safe_load(File.read(openapi_path)) 51 | expected_yaml = YAML.safe_load(File.read(expected_path)) 52 | expect(new_yaml).to eq expected_yaml 53 | ensure 54 | File.write(openapi_path, original_source) 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/apps/hanami/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | We’re sorry, but something went wrong (500) 7 | 72 | 73 | 74 | 75 |
76 |
77 |

500

78 |

We’re sorry, but something went wrong.

79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /spec/apps/hanami/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | The page you were looking for doesn’t exist (404) 7 | 72 | 73 | 74 | 75 |
76 |
77 |

404

78 |

The page you were looking for doesn’t exist.

79 |
80 |
81 | 82 | 83 | -------------------------------------------------------------------------------- /lib/rspec/openapi/extractors/rack.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Extractor for rack 4 | class << RSpec::OpenAPI::Extractors::Rack = Object.new 5 | # @param [ActionDispatch::Request] request 6 | # @param [RSpec::Core::Example] example 7 | # @return Array 8 | def request_attributes(request, example) 9 | metadata = merge_openapi_metadata(example.metadata) 10 | summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) 11 | tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) 12 | formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example) 13 | operation_id = metadata[:operation_id] 14 | required_request_params = metadata[:required_request_params] || [] 15 | security = metadata[:security] 16 | description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) 17 | deprecated = metadata[:deprecated] 18 | raw_path_params = request.path_parameters 19 | path = request.path 20 | summary ||= "#{request.method} #{path}" 21 | [ 22 | path, 23 | summary, 24 | tags, 25 | operation_id, 26 | required_request_params, 27 | raw_path_params, 28 | description, 29 | security, 30 | deprecated, 31 | formats, 32 | ] 33 | end 34 | 35 | # @param [RSpec::ExampleGroups::*] context 36 | def request_response(context) 37 | request = ActionDispatch::Request.new(context.last_request.env) 38 | request.body.rewind if request.body.respond_to?(:rewind) 39 | response = ActionDispatch::TestResponse.new(*context.last_response.to_a) 40 | 41 | [request, response] 42 | end 43 | 44 | private 45 | 46 | def merge_openapi_metadata(metadata) 47 | collect_openapi_metadata(metadata).reduce({}, &:merge) 48 | end 49 | 50 | def collect_openapi_metadata(metadata) 51 | [].tap do |result| 52 | current = metadata 53 | 54 | while current 55 | [current[:example_group], current].each do |meta| 56 | result.unshift(meta[:openapi]) if meta&.dig(:openapi) 57 | end 58 | 59 | current = current[:parent_example_group] 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/tables_controller.rb: -------------------------------------------------------------------------------- 1 | class TablesController < ApplicationController 2 | APIKEY = 'k0kubun'.freeze 3 | 4 | before_action :authenticate 5 | 6 | def index 7 | response.set_header('X-Cursor', 100) 8 | if params[:show_columns] 9 | render json: [find_table('42')] 10 | else 11 | render json: [find_table] 12 | end 13 | end 14 | 15 | def show 16 | render json: find_table(params[:id]) 17 | end 18 | 19 | def create 20 | if params[:name].blank? || params[:name] == 'some_invalid_name' 21 | render json: { error: 'invalid name parameter' }, status: 422 22 | else 23 | render json: find_table, status: 201 24 | end 25 | end 26 | 27 | def update 28 | render json: find_table(params[:id]) 29 | end 30 | 31 | def destroy 32 | if params[:no_content] 33 | return head 202 34 | end 35 | 36 | render json: find_table(params[:id]) 37 | end 38 | 39 | private 40 | 41 | def authenticate 42 | if request.headers[:authorization] != APIKEY 43 | render json: { message: 'Unauthorized' }, status: 401 44 | end 45 | end 46 | 47 | def find_table(id = nil) 48 | time = Time.parse('2020-07-17 00:00:00') 49 | case id 50 | when '1', nil 51 | { 52 | id: 1, 53 | name: 'access', 54 | description: 'logs', 55 | database: { 56 | id: 2, 57 | name: 'production', 58 | }, 59 | null_sample: nil, 60 | storage_size: 12.3, 61 | created_at: time.iso8601, 62 | updated_at: time.iso8601, 63 | } 64 | when '42' 65 | { 66 | id: 42, 67 | name: 'access', 68 | description: 'logs', 69 | database: { 70 | id: 4242, 71 | name: 'production', 72 | }, 73 | columns: [ 74 | { name: 'id', column_type: 'integer' }, 75 | { name: 'description', column_type: 'varchar' }, 76 | ], 77 | null_sample: nil, 78 | storage_size: 12.3, 79 | created_at: time.iso8601, 80 | updated_at: time.iso8601, 81 | } 82 | else 83 | raise NotFoundError 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/apps/hanami/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | require 'rack_test/app' 3 | 4 | module HanamiTest 5 | class Routes < Hanami::Routes 6 | # Add your routes here. See https://guides.hanamirb.org/routing/overview/ for details. 7 | get '/secret_items', to: 'secret_items.index' 8 | 9 | get '/tables', to: 'tables.index' 10 | get '/tables/:id', to: 'tables.show' 11 | post '/tables', to: 'tables.create' 12 | patch '/tables/:id', to: 'tables.update' 13 | delete '/tables/:id', to: 'tables.destroy' 14 | 15 | get '/images', to: 'images.index' 16 | get '/images/:id', to: 'images.show' 17 | post '/images/upload', to: 'images.upload' 18 | post '/images/upload_nested', to: 'images.upload_nested' 19 | post '/images/upload_multiple', to: 'images.upload_multiple' 20 | post '/images/upload_multiple_nested', to: 'images.upload_multiple_nested' 21 | 22 | post '/users', to: 'users.create' 23 | get '/users/:id', to: 'users.show' 24 | get '/users/active', to: 'users.active' 25 | 26 | get '/sites/:name', to: 'sites.show' 27 | get '/array_hashes/nullable', to: 'array_hashes.nullable' 28 | get '/array_hashes/non_nullable', to: 'array_hashes.non_nullable' 29 | get '/array_hashes/nested', to: 'array_hashes.nested' 30 | get '/array_hashes/empty_array', to: 'array_hashes.empty_array' 31 | get '/array_hashes/single_item', to: 'array_hashes.single_item' 32 | get '/array_hashes/non_hash_items', to: 'array_hashes.non_hash_items' 33 | get '/array_hashes/nested_arrays', to: 'array_hashes.nested_arrays' 34 | get '/array_hashes/nested_objects', to: 'array_hashes.nested_objects' 35 | get '/array_hashes/mixed_types_nested', to: 'array_hashes.mixed_types_nested' 36 | 37 | get '/test_block', to: ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['A TEST']] } 38 | 39 | slice :my_engine, at: '/my_engine' do 40 | get '/test', to: ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['ANOTHER TEST']] } 41 | get '/eng/example', to: 'eng.example' 42 | end 43 | 44 | scope 'admin' do 45 | scope 'masters' do 46 | get '/extensions', to: 'extensions.index' 47 | post '/extensions', to: 'extensions.create' 48 | end 49 | end 50 | 51 | use RackTest::App 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/rspec/openapi/result_recorder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class RSpec::OpenAPI::ResultRecorder 4 | def initialize(path_records) 5 | @path_records = path_records 6 | @error_records = {} 7 | end 8 | 9 | def record_results! 10 | @path_records.each do |path, records| 11 | # Look for a path-specific config file and run it. 12 | config_file = File.join(File.dirname(path), RSpec::OpenAPI.config_filename) 13 | begin 14 | require config_file if File.exist?(config_file) 15 | rescue StandardError => e 16 | puts "WARNING: Unable to load #{config_file}: #{e}" 17 | end 18 | 19 | title = records.first.title 20 | RSpec::OpenAPI::SchemaFile.new(path).edit do |spec| 21 | schema = RSpec::OpenAPI::DefaultSchema.build(title) 22 | schema[:info].merge!(RSpec::OpenAPI.info) 23 | RSpec::OpenAPI::SchemaMerger.merge!(spec, schema) 24 | new_from_zero = {} 25 | records.each do |record| 26 | record_schema = RSpec::OpenAPI::SchemaBuilder.build(record) 27 | RSpec::OpenAPI::SchemaMerger.merge!(spec, record_schema) 28 | RSpec::OpenAPI::SchemaMerger.merge!(new_from_zero, record_schema) 29 | rescue StandardError, NotImplementedError => e # e.g. SchemaBuilder raises a NotImplementedError 30 | @error_records[e] = record # Avoid failing the build 31 | end 32 | cleanup_schema!(new_from_zero, spec) 33 | execute_post_process_hook(path, records, spec) 34 | end 35 | end 36 | end 37 | 38 | def errors? 39 | @error_records.any? 40 | end 41 | 42 | def error_message 43 | <<~ERR_MSG 44 | RSpec::OpenAPI got errors building #{@error_records.size} requests 45 | 46 | #{@error_records.map { |e, record| "#{e.inspect}: #{record.inspect}" }.join("\n")} 47 | ERR_MSG 48 | end 49 | 50 | private 51 | 52 | def execute_post_process_hook(path, records, spec) 53 | RSpec::OpenAPI.post_process_hook.call(path, records, spec) if RSpec::OpenAPI.post_process_hook.is_a?(Proc) 54 | end 55 | 56 | def cleanup_schema!(new_from_zero, spec) 57 | RSpec::OpenAPI::SchemaCleaner.cleanup_conflicting_security_parameters!(spec) 58 | RSpec::OpenAPI::SchemaCleaner.cleanup!(spec, new_from_zero) 59 | RSpec::OpenAPI::ComponentsUpdater.update!(spec, new_from_zero) 60 | RSpec::OpenAPI::SchemaCleaner.cleanup_empty_required_array!(spec) 61 | RSpec::OpenAPI::SchemaSorter.deep_sort!(spec) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/apps/rails/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | # Run rails dev:cache to toggle caching. 17 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 18 | config.action_controller.perform_caching = true 19 | config.action_controller.enable_fragment_cache_logging = true 20 | 21 | config.cache_store = :memory_store 22 | config.public_file_server.headers = { 23 | 'Cache-Control' => "public, max-age=#{2.days.to_i}", 24 | } 25 | else 26 | config.action_controller.perform_caching = false 27 | 28 | config.cache_store = :null_store 29 | end 30 | 31 | # Store uploaded files on the local file system (see config/storage.yml for options). 32 | config.active_storage.service = :local 33 | 34 | # Don't care if the mailer can't send. 35 | config.action_mailer.raise_delivery_errors = false 36 | 37 | config.action_mailer.perform_caching = false 38 | 39 | # Print deprecation notices to the Rails logger. 40 | config.active_support.deprecation = :log 41 | 42 | # Raise an error on page load if there are pending migrations. 43 | config.active_record.migration_error = :page_load 44 | 45 | # Highlight code that triggered database queries in logs. 46 | config.active_record.verbose_query_logs = true 47 | 48 | # Debug mode disables concatenation and preprocessing of assets. 49 | # This option may cause significant delays in view rendering with a large 50 | # number of complex assets. 51 | config.assets.debug = true 52 | 53 | # Suppress logger output for asset requests. 54 | config.assets.quiet = true 55 | 56 | # Raises error for missing translations. 57 | # config.action_view.raise_on_missing_translations = true 58 | 59 | # Use an evented file watcher to asynchronously detect changes in source code, 60 | # routes, locales, etc. This feature depends on the listen gem. 61 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 62 | end 63 | -------------------------------------------------------------------------------- /spec/apps/hanami/app/actions/array_hashes/nested_objects.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module HanamiTest 4 | module Actions 5 | module ArrayHashes 6 | class NestedObjects < HanamiTest::Action 7 | def handle(request, response) 8 | response.format = :json 9 | 10 | response.body = { 11 | "items" => [ 12 | { 13 | "id" => 1, 14 | "metadata" => { 15 | "author" => "Alice", 16 | "version" => "1.0" 17 | }, 18 | "actions" => [ 19 | { 20 | "label" => "Duplicate", 21 | "modal" => { 22 | "confirm_action" => { 23 | "label" => "Duplicate", 24 | "endpoint" => nil 25 | } 26 | } 27 | }, 28 | { 29 | "label" => "Edit", 30 | }, 31 | { 32 | "label" => "Something Else Again", 33 | "modal" => { 34 | "confirm_action" => { 35 | "label" => nil, 36 | "endpoint" => nil 37 | } 38 | } 39 | } 40 | ] 41 | }, 42 | { 43 | "id" => 2, 44 | "metadata" => { 45 | "author" => "Bob", 46 | "version" => "2.0", 47 | "reviewed" => true 48 | }, 49 | "actions" => [ 50 | { 51 | "label" => "Duplicate", 52 | "modal" => { 53 | "confirm_action" => { 54 | "label" => "Duplicate", 55 | "endpoint" => nil 56 | } 57 | } 58 | }, 59 | { 60 | "label" => "Edit", 61 | }, 62 | { 63 | "label" => "Something Else Again", 64 | "modal" => { 65 | "confirm_action" => { 66 | "label" => nil, 67 | "endpoint" => nil 68 | } 69 | } 70 | } 71 | ] 72 | }, 73 | { 74 | "id" => 3, 75 | "metadata" => { 76 | "author" => "Charlie" 77 | } 78 | } 79 | ] 80 | }.to_json 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /.github/workflows/create_release.yml: -------------------------------------------------------------------------------- 1 | name: prepare release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Version to release (e.g. 0.21.3 or v0.21.3)' 8 | required: true 9 | 10 | jobs: 11 | push: 12 | name: Prepare release PR 13 | runs-on: ubuntu-latest 14 | 15 | permissions: 16 | id-token: write # IMPORTANT: this permission is mandatory for trusted publishing 17 | contents: write # required to push release branch and open PR 18 | pull-requests: write # required to create the release PR with GITHUB_TOKEN 19 | 20 | steps: 21 | - uses: actions/checkout@v5 22 | with: 23 | fetch-depth: 0 24 | 25 | - name: Bump version.rb 26 | run: | 27 | set -euo pipefail 28 | version="${{ github.event.inputs.version }}" 29 | version="${version#v}" 30 | 31 | echo "VERSION_NO_V=$version" >> "$GITHUB_ENV" 32 | echo "VERSION_TAG=v$version" >> "$GITHUB_ENV" 33 | 34 | current_version=$(ruby -e "require_relative './lib/rspec/openapi/version'; puts RSpec::OpenAPI::VERSION") 35 | VERSION_NO_V="$version" CURRENT_VERSION="$current_version" ruby - <<'RUBY' 36 | version = ENV.fetch('VERSION_NO_V') 37 | unless version.match?(/\A\d+(?:\.\d+)*\z/) 38 | warn "Invalid version format: #{version}" 39 | exit 1 40 | end 41 | 42 | require 'rubygems' 43 | new_version = Gem::Version.new(version) 44 | current_version = Gem::Version.new(ENV.fetch('CURRENT_VERSION')) 45 | if new_version <= current_version 46 | warn "Given version (#{new_version}) must be newer than current version (#{current_version})" 47 | exit 1 48 | end 49 | RUBY 50 | 51 | ruby -pi -e "sub(/VERSION = .*/, \"VERSION = '$version'\")" lib/rspec/openapi/version.rb 52 | git status --short 53 | 54 | - name: Commit version bump 55 | run: | 56 | set -euo pipefail 57 | version="$VERSION_NO_V" 58 | 59 | release_branch="release/v${version}" 60 | echo "RELEASE_BRANCH=${release_branch}" >> "$GITHUB_ENV" 61 | 62 | git config user.name "github-actions[bot]" 63 | git config user.email "github-actions[bot]@users.noreply.github.com" 64 | git commit -am "Bump version to ${version}" 65 | git push origin "HEAD:${release_branch}" 66 | 67 | - name: Open release PR 68 | uses: peter-evans/create-pull-request@v6 69 | with: 70 | token: ${{ secrets.GITHUB_TOKEN }} 71 | add-paths: | 72 | lib/rspec/openapi/version.rb 73 | branch: ${{ env.RELEASE_BRANCH }} 74 | title: Release v${{ env.VERSION_NO_V }} 75 | commit-message: Bump version to ${{ env.VERSION_NO_V }} 76 | body: | 77 | Automated release PR created by workflow_dispatch. 78 | - Version: v${{ env.VERSION_NO_V }} 79 | - Triggered by: ${{ github.actor }} 80 | -------------------------------------------------------------------------------- /lib/rspec/openapi.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rspec/openapi/version' 4 | require 'rspec/openapi/components_updater' 5 | require 'rspec/openapi/default_schema' 6 | require 'rspec/openapi/record_builder' 7 | require 'rspec/openapi/result_recorder' 8 | require 'rspec/openapi/schema_builder' 9 | require 'rspec/openapi/schema_file' 10 | require 'rspec/openapi/schema_merger' 11 | require 'rspec/openapi/schema_cleaner' 12 | require 'rspec/openapi/schema_sorter' 13 | require 'rspec/openapi/key_transformer' 14 | require 'rspec/openapi/shared_hooks' 15 | require 'rspec/openapi/extractors' 16 | require 'rspec/openapi/extractors/rack' 17 | 18 | module RSpec::OpenAPI 19 | class Config 20 | class << self 21 | attr_accessor :debug_enabled 22 | 23 | def load_environment_settings 24 | @debug_enabled = ['', '1', 'true'].include?(ENV['DEBUG']&.downcase) 25 | end 26 | end 27 | end 28 | 29 | @path = 'doc/openapi.yaml' 30 | @title = File.basename(Dir.pwd) 31 | @comment = nil 32 | @enable_example = true 33 | @description_builder = ->(example) { example.description } 34 | @summary_builder = ->(example) { example.metadata[:summary] } 35 | @tags_builder = ->(example) { example.metadata[:tags] } 36 | @formats_builder = ->(example) { example.metadata[:formats] } 37 | @info = {} 38 | @application_version = '1.0.0' 39 | @request_headers = [] 40 | @servers = [] 41 | @security_schemes = [] 42 | @example_types = %i[request] 43 | @response_headers = [] 44 | @path_records = Hash.new { |h, k| h[k] = [] } 45 | @ignored_path_params = %i[controller action format] 46 | @ignored_paths = [] 47 | @post_process_hook = nil 48 | 49 | # This is the configuraion override file name we look for within each path. 50 | @config_filename = 'rspec_openapi.rb' 51 | 52 | class << self 53 | attr_accessor :path, 54 | :title, 55 | :comment, 56 | :enable_example, 57 | :description_builder, 58 | :summary_builder, 59 | :tags_builder, 60 | :formats_builder, 61 | :info, 62 | :application_version, 63 | :request_headers, 64 | :servers, 65 | :security_schemes, 66 | :example_types, 67 | :response_headers, 68 | :path_records, 69 | :ignored_paths, 70 | :ignored_path_params, 71 | :post_process_hook 72 | 73 | attr_reader :config_filename 74 | end 75 | end 76 | 77 | if ENV['OPENAPI'] 78 | RSpec::OpenAPI::Config.load_environment_settings 79 | 80 | begin 81 | require 'hanami' 82 | rescue LoadError 83 | warn 'Hanami not detected' if RSpec::OpenAPI::Config.debug_enabled 84 | else 85 | require 'rspec/openapi/extractors/hanami' 86 | end 87 | 88 | begin 89 | require 'rails' 90 | rescue LoadError 91 | warn 'Rails not detected' if RSpec::OpenAPI::Config.debug_enabled 92 | else 93 | require 'rspec/openapi/extractors/rails' 94 | end 95 | end 96 | 97 | require 'rspec/openapi/minitest_hooks' if Object.const_defined?('Minitest') 98 | require 'rspec/openapi/rspec_hooks' if ENV['OPENAPI'] && Object.const_defined?('RSpec') 99 | -------------------------------------------------------------------------------- /lib/rspec/openapi/record_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'action_dispatch' 4 | require 'rspec/openapi/record' 5 | 6 | class << RSpec::OpenAPI::RecordBuilder = Object.new 7 | # @param [RSpec::ExampleGroups::*] context 8 | # @param [RSpec::Core::Example] example 9 | # @return [RSpec::OpenAPI::Record,nil] 10 | def build(context, example:, extractor:) 11 | request, response = extractor.request_response(context) 12 | return if request.nil? 13 | 14 | title = RSpec::OpenAPI.title.then { |t| t.is_a?(Proc) ? t.call(example) : t } 15 | path, summary, tags, operation_id, required_request_params, raw_path_params, description, security, deprecated, formats = 16 | extractor.request_attributes(request, example) 17 | 18 | return if RSpec::OpenAPI.ignored_paths.any? { |ignored_path| path.match?(ignored_path) } 19 | 20 | request_headers, response_headers = extract_headers(request, response) 21 | 22 | RSpec::OpenAPI::Record.new( 23 | title: title, 24 | http_method: request.method, 25 | path: path, 26 | path_params: raw_path_params, 27 | query_params: request.query_parameters, 28 | request_params: raw_request_params(request), 29 | required_request_params: required_request_params, 30 | request_content_type: request.media_type, 31 | request_headers: request_headers, 32 | summary: summary, 33 | tags: tags, 34 | formats: formats, 35 | operation_id: operation_id, 36 | description: description, 37 | security: security, 38 | deprecated: deprecated, 39 | status: response.status, 40 | response_body: safe_parse_body(response, response.media_type), 41 | response_headers: response_headers, 42 | response_content_type: response.media_type, 43 | response_content_disposition: response.header['Content-Disposition'], 44 | ).freeze 45 | end 46 | 47 | private 48 | 49 | def safe_parse_body(response, media_type) 50 | # Use raw body, because Nokogiri-parsed HTML are modified (new lines injection, meta injection, and so on) :( 51 | return response.body if media_type == 'text/html' 52 | 53 | response.parsed_body 54 | rescue JSON::ParserError 55 | nil 56 | end 57 | 58 | def extract_headers(request, response) 59 | request_headers = RSpec::OpenAPI.request_headers.each_with_object([]) do |header, headers_arr| 60 | header_key = header.gsub('-', '_').upcase.to_sym 61 | 62 | header_value = request.get_header(['HTTP', header_key].join('_')) || 63 | request.get_header(header_key) || 64 | request.get_header(header_key.to_s) 65 | headers_arr << [header, header_value] if header_value 66 | end 67 | response_headers = RSpec::OpenAPI.response_headers.each_with_object([]) do |header, headers_arr| 68 | header_key = header 69 | header_value = response.headers[header_key] 70 | headers_arr << [header_key, header_value] if header_value 71 | end 72 | [request_headers, response_headers] 73 | end 74 | 75 | # workaround to get real request parameters 76 | # because ActionController::ParamsWrapper overwrites request_parameters 77 | def raw_request_params(request) 78 | original = request.delete_header('action_dispatch.request.request_parameters') 79 | request.request_parameters 80 | ensure 81 | request.set_header('action_dispatch.request.request_parameters', original) 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /lib/rspec/openapi/extractors/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Extractor for rails 4 | class << RSpec::OpenAPI::Extractors::Rails = Object.new 5 | # @param [ActionDispatch::Request] request 6 | # @param [RSpec::Core::Example] example 7 | # @return Array 8 | def request_attributes(request, example) 9 | # Reverse the destructive modification by Rails https://github.com/rails/rails/blob/v6.0.3.4/actionpack/lib/action_dispatch/journey/router.rb#L33-L41 10 | fixed_request = request.dup 11 | fixed_request.path_info = File.join(request.script_name, request.path_info) if request.script_name.present? 12 | 13 | route, path = find_rails_route(fixed_request) 14 | 15 | return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless path 16 | 17 | raise "No route matched for #{fixed_request.request_method} #{fixed_request.path_info}" if route.nil? 18 | 19 | metadata = merge_openapi_metadata(example.metadata) 20 | summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) 21 | tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) 22 | formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example) 23 | 24 | operation_id = metadata[:operation_id] 25 | required_request_params = metadata[:required_request_params] || [] 26 | security = metadata[:security] 27 | description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) 28 | deprecated = metadata[:deprecated] 29 | raw_path_params = request.path_parameters 30 | 31 | summary ||= route.requirements[:action] 32 | tags ||= [route.requirements[:controller]&.classify].compact 33 | # :controller and :action always exist. :format is added when routes is configured as such. 34 | # TODO: Use .except(:controller, :action, :format) when we drop support for Ruby 2.x 35 | raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params)) 36 | 37 | summary ||= "#{request.method} #{path}" 38 | 39 | [ 40 | path, 41 | summary, 42 | tags, 43 | operation_id, 44 | required_request_params, 45 | raw_path_params, 46 | description, 47 | security, 48 | deprecated, 49 | formats, 50 | ] 51 | end 52 | 53 | # @param [RSpec::ExampleGroups::*] context 54 | def request_response(context) 55 | [context.request, context.response] 56 | end 57 | 58 | private 59 | 60 | def merge_openapi_metadata(metadata) 61 | collect_openapi_metadata(metadata).reduce({}, &:merge) 62 | end 63 | 64 | def collect_openapi_metadata(metadata) 65 | [].tap do |result| 66 | current = metadata 67 | 68 | while current 69 | [current[:example_group], current].each do |meta| 70 | result.unshift(meta[:openapi]) if meta&.dig(:openapi) 71 | end 72 | 73 | current = current[:parent_example_group] 74 | end 75 | end 76 | end 77 | 78 | # @param [ActionDispatch::Request] request 79 | def find_rails_route(request, app: Rails.application, path_prefix: '') 80 | app.routes.router.recognize(request) do |route, _parameters| 81 | path = route.path.spec.to_s.delete_suffix('(.:format)') 82 | 83 | if route.app.matches?(request) 84 | if route.app.engine? 85 | route, path = find_rails_route(request, app: route.app.app, path_prefix: path) 86 | next if route.nil? 87 | end 88 | 89 | # Params are empty when it is Engine or Rack app. 90 | # In that case, we can't handle parameters in path. 91 | return [route, nil] if request.params.empty? 92 | 93 | return [route, path_prefix + path] 94 | end 95 | end 96 | 97 | nil 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/rspec/openapi/schema_merger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class << RSpec::OpenAPI::SchemaMerger = Object.new 4 | # @param [Hash] base 5 | # @param [Hash] spec 6 | def merge!(base, spec) 7 | spec = RSpec::OpenAPI::KeyTransformer.symbolize(spec) 8 | base.replace(RSpec::OpenAPI::KeyTransformer.symbolize(base)) 9 | merge_schema!(base, spec) 10 | end 11 | 12 | private 13 | 14 | # Not doing `base.replace(deep_merge(base, spec))` to preserve key orders. 15 | # Also this needs to be aware of OpenAPI details because a Hash-like structure 16 | # may be an array whose Hash elements have a key name. 17 | # 18 | # TODO: Should we probably force-merge `summary` regardless of manual modifications? 19 | def merge_schema!(base, spec) 20 | if (options = base[:oneOf]) 21 | merge_closest_match!(options, spec) 22 | 23 | return base 24 | end 25 | 26 | spec.each do |key, value| 27 | if base[key].is_a?(Hash) && value.is_a?(Hash) 28 | # If the new value has oneOf, replace the entire value instead of merging 29 | if value.key?(:oneOf) 30 | base[key] = value 31 | else 32 | merge_schema!(base[key], value) unless base[key].key?(:$ref) 33 | end 34 | elsif base[key].is_a?(Array) && value.is_a?(Array) 35 | # parameters need to be merged as if `name` and `in` were the Hash keys. 36 | merge_arrays(base, key, value) 37 | else 38 | # do not ADD `properties` or `required` fields if `additionalProperties` field is present 39 | base[key] = value unless base.key?(:additionalProperties) && %i[properties required].include?(key) 40 | end 41 | end 42 | base 43 | end 44 | 45 | def merge_arrays(base, key, value) 46 | base[key] = case key 47 | when :parameters 48 | merge_parameters(base, key, value) 49 | when :required 50 | # Preserve properties that appears in all test cases 51 | value & base[key] 52 | else 53 | # last one wins 54 | value 55 | end 56 | end 57 | 58 | def merge_parameters(base, key, value) 59 | all_parameters = value | base[key] 60 | 61 | unique_base_parameters = build_unique_params(base, key) 62 | 63 | all_parameters = all_parameters.map do |parameter| 64 | base_parameter = unique_base_parameters[[parameter[:name], parameter[:in]]] || {} 65 | base_parameter ? base_parameter.merge(parameter) : parameter 66 | end 67 | 68 | all_parameters.uniq! { |param| param.slice(:name, :in) } 69 | base[key] = all_parameters 70 | end 71 | 72 | def build_unique_params(base, key) 73 | base[key].each_with_object({}) do |parameter, hash| 74 | hash[[parameter[:name], parameter[:in]]] = parameter 75 | end 76 | end 77 | 78 | SIMILARITY_THRESHOLD = 0.5 79 | 80 | def merge_closest_match!(options, spec) 81 | score, option = options.map { |option| [similarity(option, spec), option] }.max_by(&:first) 82 | 83 | return if option&.key?(:$ref) 84 | 85 | return if spec[:oneOf] 86 | 87 | if score.to_f > SIMILARITY_THRESHOLD 88 | merge_schema!(option, spec) 89 | else 90 | options.push(spec) 91 | end 92 | end 93 | 94 | def similarity(first, second) 95 | return 1 if first == second 96 | 97 | score = 98 | case [first.class, second.class] 99 | when [Array, Array] 100 | (first & second).size / [first.size, second.size].max.to_f 101 | when [Hash, Hash] 102 | return 1 if first.merge(second).key?(:$ref) 103 | 104 | intersection = first.keys & second.keys 105 | total_size = [first.size, second.size].max.to_f 106 | 107 | intersection.sum { |key| similarity(first[key], second[key]) } / total_size 108 | else 109 | 0 110 | end 111 | 112 | score.finite? ? score : 0 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /lib/rspec/openapi/extractors/hanami.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dry/inflector' 4 | require 'hanami' 5 | 6 | # https://github.com/hanami/router/blob/97f75b8529574bd4ff23165460e82a6587bc323c/lib/hanami/router/inspector.rb#L13 7 | class Inspector 8 | attr_accessor :routes, :inflector 9 | 10 | def initialize(routes: []) 11 | @routes = routes 12 | @inflector = Dry::Inflector.new 13 | end 14 | 15 | def add_route(route) 16 | routes.push(route) 17 | end 18 | 19 | def call(verb, path) 20 | route = routes.find { |r| r.http_method == verb && r.path == path } 21 | 22 | if route.to.is_a?(Proc) 23 | { 24 | tags: [], 25 | summary: "#{verb} #{path}", 26 | } 27 | else 28 | data = route.to.split('.') 29 | 30 | { 31 | tags: [inflector.classify(data[0])], 32 | summary: data[1], 33 | } 34 | end 35 | end 36 | end 37 | 38 | InspectorAnalyzer = Inspector.new 39 | 40 | # Add default parameter to load inspector before test cases run 41 | module InspectorAnalyzerPrepender 42 | def router(inspector: InspectorAnalyzer) 43 | super 44 | end 45 | end 46 | 47 | Hanami::Slice::ClassMethods.prepend(InspectorAnalyzerPrepender) 48 | 49 | # Extractor for hanami 50 | class << RSpec::OpenAPI::Extractors::Hanami = Object.new 51 | # @param [ActionDispatch::Request] request 52 | # @param [RSpec::Core::Example] example 53 | # @return Array 54 | def request_attributes(request, example) 55 | route = Hanami.app.router.recognize(Rack::MockRequest.env_for(request.path, method: request.method)) 56 | 57 | return RSpec::OpenAPI::Extractors::Rack.request_attributes(request, example) unless route.routable? 58 | 59 | metadata = merge_openapi_metadata(example.metadata) 60 | summary = metadata[:summary] || RSpec::OpenAPI.summary_builder.call(example) 61 | tags = metadata[:tags] || RSpec::OpenAPI.tags_builder.call(example) 62 | formats = metadata[:formats] || RSpec::OpenAPI.formats_builder.curry.call(example) 63 | operation_id = metadata[:operation_id] 64 | required_request_params = metadata[:required_request_params] || [] 65 | security = metadata[:security] 66 | description = metadata[:description] || RSpec::OpenAPI.description_builder.call(example) 67 | deprecated = metadata[:deprecated] 68 | path = request.path 69 | 70 | raw_path_params = route.params 71 | 72 | result = InspectorAnalyzer.call(request.method, add_id(path, route)) 73 | 74 | summary ||= result[:summary] 75 | tags ||= result[:tags] 76 | path = add_openapi_id(path, route) 77 | 78 | raw_path_params = raw_path_params.slice(*(raw_path_params.keys - RSpec::OpenAPI.ignored_path_params)) 79 | 80 | [ 81 | path, 82 | summary, 83 | tags, 84 | operation_id, 85 | required_request_params, 86 | raw_path_params, 87 | description, 88 | security, 89 | deprecated, 90 | formats, 91 | ] 92 | end 93 | 94 | # @param [RSpec::ExampleGroups::*] context 95 | def request_response(context) 96 | request = ActionDispatch::Request.new(context.last_request.env) 97 | request.body.rewind if request.body.respond_to?(:rewind) 98 | response = ActionDispatch::TestResponse.new(*context.last_response.to_a) 99 | 100 | [request, response] 101 | end 102 | 103 | private 104 | 105 | def merge_openapi_metadata(metadata) 106 | collect_openapi_metadata(metadata).reduce({}, &:merge) 107 | end 108 | 109 | def collect_openapi_metadata(metadata) 110 | [].tap do |result| 111 | current = metadata 112 | 113 | while current 114 | [current[:example_group], current].each do |meta| 115 | result.unshift(meta[:openapi]) if meta&.dig(:openapi) 116 | end 117 | 118 | current = current[:parent_example_group] 119 | end 120 | end 121 | end 122 | 123 | def add_id(path, route) 124 | return path if route.params.empty? 125 | 126 | route.params.each_pair do |key, value| 127 | path = path.sub("/#{value}", "/:#{key}") 128 | end 129 | 130 | path 131 | end 132 | 133 | def add_openapi_id(path, route) 134 | return path if route.params.empty? 135 | 136 | route.params.each_pair do |key, value| 137 | path = path.sub("/#{value}", "/{#{key}}") 138 | end 139 | 140 | path 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /lib/rspec/openapi/components_updater.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'hash_helper' 4 | 5 | class << RSpec::OpenAPI::ComponentsUpdater = Object.new 6 | # @param [Hash] base 7 | # @param [Hash] fresh 8 | def update!(base, fresh) 9 | # Top-level schema: Used as the body of request or response 10 | top_level_refs = paths_to_top_level_refs(base) 11 | return if top_level_refs.empty? 12 | 13 | fresh_schemas = build_fresh_schemas(top_level_refs, base, fresh) 14 | 15 | # Nested schema: References in Top-level schemas. May contain some top-level schema. 16 | generated_schema_names = fresh_schemas.keys 17 | nested_refs = find_non_top_level_nested_refs(base, generated_schema_names) 18 | nested_refs.each do |paths| 19 | # Slice between the parent name and the element before "$ref" 20 | # ["components", "schema", "Table", "properties", "database", "$ref"] 21 | # 0 1 2 ^....................^ 22 | # ["components", "schema", "Table", "properties", "columns", "items", "$ref"] 23 | # 0 1 2 ^...............................^ 24 | # ["components", "schema", "Table", "properties", "owner", "properties", "company", "$ref"] 25 | # 0 1 2 ^...........................................^ 26 | needle = paths.reject { |path| path.is_a?(Integer) || path == :oneOf } 27 | needle = needle.slice(2, needle.size - 3) 28 | nested_schema = fresh_schemas.dig(*needle) 29 | 30 | # Skip if the property using $ref is not found in the parent schema. The property may be removed. 31 | next if nested_schema.nil? 32 | 33 | schema_name = base.dig(*paths)&.gsub('#/components/schemas/', '')&.to_sym 34 | fresh_schemas[schema_name] ||= {} 35 | RSpec::OpenAPI::SchemaMerger.merge!(fresh_schemas[schema_name], nested_schema) 36 | end 37 | 38 | RSpec::OpenAPI::SchemaMerger.merge!(base, { components: { schemas: fresh_schemas } }) 39 | RSpec::OpenAPI::SchemaCleaner.cleanup_components_schemas!(base, { components: { schemas: fresh_schemas } }) 40 | end 41 | 42 | private 43 | 44 | def build_fresh_schemas(references, base, fresh) 45 | references.inject({}) do |acc, paths| 46 | ref_link = dig_schema(base, paths)[:$ref] 47 | schema_name = ref_link.to_s.gsub('#/components/schemas/', '') 48 | schema_body = dig_schema(fresh, paths.reject { |path| path.is_a?(Integer) }) 49 | 50 | RSpec::OpenAPI::SchemaMerger.merge!(acc, { schema_name => schema_body }) 51 | end 52 | end 53 | 54 | def dig_schema(obj, paths) 55 | # Response code can be an integer 56 | paths = paths.map { |path| path.is_a?(Integer) ? path : path.to_sym } 57 | item_schema = obj.dig(*paths, :schema, :items) 58 | object_schema = obj.dig(*paths, :schema) 59 | one_of_schema = obj.dig(*paths.take(paths.size - 1), :schema, :oneOf, paths.last) 60 | 61 | item_schema || object_schema || one_of_schema 62 | end 63 | 64 | def paths_to_top_level_refs(base) 65 | request_bodies = RSpec::OpenAPI::HashHelper.matched_paths(base, 'paths.*.*.requestBody.content.application/json') 66 | responses = RSpec::OpenAPI::HashHelper.matched_paths(base, 'paths.*.*.responses.*.content.application/json') 67 | (request_bodies + responses).flat_map do |paths| 68 | object_paths = find_object_refs(base, paths) 69 | one_of_paths = find_one_of_refs(base, paths) 70 | 71 | object_paths || one_of_paths || [] 72 | end 73 | end 74 | 75 | def find_non_top_level_nested_refs(base, generated_names) 76 | nested_refs = [ 77 | *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.$ref'), 78 | *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties.*.items.$ref'), 79 | *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'oneOf.*.$ref'), 80 | ] 81 | # Reject already-generated schemas to reduce unnecessary loop 82 | nested_refs.reject do |paths| 83 | ref_link = base.dig(*paths) 84 | schema_name = ref_link.gsub('#/components/schemas/', '') 85 | generated_names.include?(schema_name) 86 | end 87 | end 88 | 89 | def find_one_of_refs(base, paths) 90 | dig_schema(base, paths)&.dig(:oneOf)&.map&.with_index do |schema, index| 91 | paths + [index] if schema&.dig(:$ref)&.start_with?('#/components/schemas/') 92 | end&.compact 93 | end 94 | 95 | def find_object_refs(base, paths) 96 | [paths] if dig_schema(base, paths)&.dig(:$ref)&.start_with?('#/components/schemas/') 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/requests/rails_smart_merge_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV['TZ'] ||= 'UTC' 4 | ENV['RAILS_ENV'] ||= 'test' 5 | ENV['OPENAPI_OUTPUT'] ||= 'json' 6 | 7 | require File.expand_path('../apps/rails/config/environment', __dir__) 8 | require 'rspec/rails' 9 | 10 | RSpec::OpenAPI.request_headers = %w[X-Authorization-Token] 11 | RSpec::OpenAPI.response_headers = %w[X-Cursor] 12 | RSpec::OpenAPI.path = File.expand_path("../apps/rails/doc/smart/openapi.#{ENV.fetch('OPENAPI_OUTPUT', nil)}", __dir__) 13 | RSpec::OpenAPI.comment = <<~COMMENT 14 | This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi 15 | 16 | When you write a spec in spec/requests, running the spec with `OPENAPI=1 rspec` will 17 | update this file automatically. You can also manually edit this file. 18 | COMMENT 19 | RSpec::OpenAPI.servers = [{ url: 'http://localhost:3000' }] 20 | RSpec::OpenAPI.security_schemes = { 21 | 'Scheme1' => { 22 | description: 'Authentication scheme', 23 | type: 'http', 24 | scheme: 'bearer', 25 | bearerFormat: 'JWT', 26 | }, 27 | } 28 | 29 | RSpec::OpenAPI.info = { 30 | description: 'My beautiful API', 31 | license: { 32 | name: 'Apache 2.0', 33 | url: 'https://www.apache.org/licenses/LICENSE-2.0.html', 34 | }, 35 | } 36 | 37 | RSpec::OpenAPI.formats_builder = ->(_example, key) { key.end_with?('_at') ? 'date-time' : nil } 38 | 39 | # Small subset of `rails_spec.rb` with slight changes 40 | RSpec.describe 'Tables', type: :request do 41 | describe '#index', openapi: { required_request_params: 'show_columns', operation_id: 'table-index' } do 42 | context it 'returns a list of tables' do 43 | it 'with flat query parameters' do 44 | # These new params replace them in old spec 45 | get '/tables', params: { page: '42', per: '10', show_columns: true }, 46 | headers: { authorization: 'k0kubun', 'X-Authorization-Token': 'token' } 47 | response.set_header('X-Cursor', 100) 48 | expect(response.status).to eq(200) 49 | end 50 | end 51 | 52 | it 'does not return tables if unauthorized' do 53 | get '/tables' 54 | expect(response.status).to eq(401) 55 | end 56 | end 57 | 58 | describe '#show' do 59 | it 'returns a table with changes !!!' do 60 | get '/tables/1', headers: { authorization: 'k0kubun' } 61 | expect(response.status).to eq(200) 62 | end 63 | end 64 | end 65 | 66 | RSpec.describe 'Users', type: :request do 67 | describe '#create' do 68 | it 'accepts missing avatar_url' do 69 | post '/users', headers: { authorization: 'k0kubun', 'Content-Type': 'application/json' }, params: { 70 | name: 'alice', 71 | }.to_json 72 | expect(response.status).to eq(201) 73 | end 74 | 75 | it 'accepts nested object' do 76 | post '/users', headers: { authorization: 'k0kubun', 'Content-Type': 'application/json' }, params: { 77 | name: 'alice', 78 | foo: { 79 | bar: { 80 | baz: 42, 81 | }, 82 | }, 83 | }.to_json 84 | expect(response.status).to eq(201) 85 | end 86 | 87 | it 'accepts nested object where some fields are missing' do 88 | post '/users', headers: { authorization: 'k0kubun', 'Content-Type': 'application/json' }, params: { 89 | name: 'alice', 90 | foo: { 91 | bar: {}, 92 | }, 93 | }.to_json 94 | expect(response.status).to eq(201) 95 | end 96 | 97 | it 'can accept empty body' do 98 | post '/users', headers: { authorization: 'k0kubun', 'Content-Type': 'application/json' }, params: {}.to_json 99 | expect(response.status).to eq(201) 100 | end 101 | 102 | it 'returns an user' do 103 | post '/users', headers: { authorization: 'k0kubun', 'Content-Type': 'application/json' }, params: { 104 | name: 'alice', 105 | avatar_url: 'https://example.com/avatar.png', 106 | }.to_json 107 | expect(response.status).to eq(201) 108 | end 109 | end 110 | 111 | describe '#show' do 112 | it 'returns a user' do 113 | get '/users/1' 114 | expect(response.status).to eq(200) 115 | end 116 | 117 | it 'returns a user whose fields may be missing' do 118 | get '/users/2' 119 | expect(response.status).to eq(200) 120 | end 121 | end 122 | 123 | describe '#active' do 124 | it 'returns a boolean' do 125 | get '/users/1/active' 126 | expect(response.status).to eq(200) 127 | end 128 | end 129 | end 130 | 131 | RSpec.describe 'Pages', type: :request do 132 | describe '#get' do 133 | it 'return HTML' do 134 | get '/pages' 135 | expect(response.status).to eq(200) 136 | end 137 | 138 | it 'return no content' do 139 | get '/pages?head=1' 140 | expect(response.status).to eq(204) 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /lib/rspec/openapi/schema_cleaner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # For Ruby 3.0+ 4 | require 'set' 5 | 6 | require_relative 'hash_helper' 7 | 8 | class << RSpec::OpenAPI::SchemaCleaner = Object.new 9 | # Cleanup the properties, of component schemas, that exists in the base but not in the spec. 10 | # 11 | # @param [Hash] base 12 | # @param [Hash] spec 13 | def cleanup_components_schemas!(base, spec) 14 | cleanup_hash!(base, spec, 'components.schemas.*') 15 | cleanup_hash!(base, spec, 'components.schemas.*.properties.*') 16 | end 17 | 18 | # Cleanup specific elements that exists in the base but not in the spec 19 | # 20 | # @param [Hash] base 21 | # @param [Hash] spec 22 | def cleanup!(base, spec) 23 | # cleanup URLs 24 | cleanup_hash!(base, spec, 'paths.*') 25 | 26 | # cleanup HTTP methods 27 | cleanup_hash!(base, spec, 'paths.*.*') 28 | 29 | # cleanup parameters 30 | cleanup_array!(base, spec, 'paths.*.*.parameters', %i[name in]) 31 | 32 | # cleanup requestBody 33 | cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.schema.properties.*') 34 | cleanup_hash!(base, spec, 'paths.*.*.requestBody.content.application/json.example.*') 35 | 36 | # cleanup responses 37 | cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.schema.properties.*') 38 | cleanup_hash!(base, spec, 'paths.*.*.responses.*.content.application/json.example.*') 39 | base 40 | end 41 | 42 | def cleanup_conflicting_security_parameters!(base) 43 | security_schemes = base.dig(:components, :securitySchemes) || {} 44 | 45 | return if security_schemes.empty? 46 | 47 | paths_to_security_definitions = RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'security') 48 | 49 | paths_to_security_definitions.each do |path| 50 | parent_path_definition = base.dig(*path.take(path.length - 1)) 51 | 52 | security_schemes.each do |security_scheme_name, security_scheme| 53 | remove_parameters_conflicting_with_security_scheme!( 54 | parent_path_definition, security_scheme, security_scheme_name, 55 | ) 56 | end 57 | end 58 | end 59 | 60 | def cleanup_empty_required_array!(base) 61 | paths_to_objects = [ 62 | *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'components.schemas', 'properties'), 63 | *RSpec::OpenAPI::HashHelper.matched_paths_deeply_nested(base, 'paths', 'properties'), 64 | ] 65 | paths_to_objects.each do |path| 66 | parent = base.dig(*path.take(path.length - 1)) 67 | # "required" array must not be present if empty 68 | parent.delete(:required) if parent[:required] && parent[:required].empty? 69 | end 70 | end 71 | 72 | private 73 | 74 | def remove_parameters_conflicting_with_security_scheme!(path_definition, security_scheme, security_scheme_name) 75 | return unless path_definition[:security] 76 | return unless path_definition[:parameters] 77 | return unless path_definition.dig(:security, 0).keys.include?(security_scheme_name) 78 | 79 | path_definition[:parameters].reject! do |parameter| 80 | parameter[:in] == security_scheme[:in] && # same location (ie. header) 81 | parameter[:name] == security_scheme[:name] # same name (ie. AUTHORIZATION) 82 | end 83 | path_definition.delete(:parameters) if path_definition[:parameters].empty? 84 | end 85 | 86 | def cleanup_array!(base, spec, selector, fields_for_identity = []) 87 | marshal = lambda do |obj| 88 | Marshal.dump(slice(obj, fields_for_identity)) 89 | end 90 | 91 | RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths| 92 | target_array = base.dig(*paths) 93 | spec_array = spec.dig(*paths) 94 | next unless target_array.is_a?(Array) && spec_array.is_a?(Array) 95 | 96 | spec_identities = Set.new(spec_array.map(&marshal)) 97 | target_array.select! { |e| spec_identities.include?(marshal.call(e)) } 98 | target_array.sort_by! { |param| fields_for_identity.map { |f| param[f] }.join('-') } 99 | # Keep the last duplicate to produce the result stably 100 | deduplicated = target_array.reverse.uniq { |param| slice(param, fields_for_identity) }.reverse 101 | target_array.replace(deduplicated) 102 | end 103 | base 104 | end 105 | 106 | def cleanup_hash!(base, spec, selector) 107 | RSpec::OpenAPI::HashHelper.matched_paths(base, selector).each do |paths| 108 | exist_in_base = !base.dig(*paths).nil? 109 | not_in_spec = spec.dig(*paths).nil? 110 | if exist_in_base && not_in_spec 111 | if paths.size == 1 112 | base.delete(paths.last) 113 | else 114 | parent_node = base.dig(*paths[0..-2]) 115 | parent_node.delete(paths.last) 116 | end 117 | end 118 | end 119 | base 120 | end 121 | 122 | def slice(obj, fields_for_identity) 123 | if fields_for_identity.any? 124 | obj.slice(*fields_for_identity) 125 | else 126 | obj 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/apps/rails/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 18 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 19 | # config.require_master_key = true 20 | 21 | # Disable serving static files from the `/public` folder by default since 22 | # Apache or NGINX already handles this. 23 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 24 | 25 | # Compress CSS using a preprocessor. 26 | # config.assets.css_compressor = :sass 27 | 28 | # Do not fallback to assets pipeline if a precompiled asset is missed. 29 | config.assets.compile = false 30 | 31 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 32 | # config.action_controller.asset_host = 'http://assets.example.com' 33 | 34 | # Specifies the header that your server uses for sending files. 35 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 36 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 37 | 38 | # Store uploaded files on the local file system (see config/storage.yml for options). 39 | config.active_storage.service = :local 40 | 41 | # Mount Action Cable outside main process or domain. 42 | # config.action_cable.mount_path = nil 43 | # config.action_cable.url = 'wss://example.com/cable' 44 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 45 | 46 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 47 | # config.force_ssl = true 48 | 49 | # Use the lowest log level to ensure availability of diagnostic information 50 | # when problems arise. 51 | config.log_level = :debug 52 | 53 | # Prepend all log lines with the following tags. 54 | config.log_tags = [:request_id] 55 | 56 | # Use a different cache store in production. 57 | # config.cache_store = :mem_cache_store 58 | 59 | # Use a real queuing backend for Active Job (and separate queues per environment). 60 | # config.active_job.queue_adapter = :resque 61 | # config.active_job.queue_name_prefix = "railsapp_production" 62 | 63 | config.action_mailer.perform_caching = false 64 | 65 | # Ignore bad email addresses and do not raise email delivery errors. 66 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 67 | # config.action_mailer.raise_delivery_errors = false 68 | 69 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 70 | # the I18n.default_locale when a translation cannot be found). 71 | config.i18n.fallbacks = true 72 | 73 | # Send deprecation notices to registered listeners. 74 | config.active_support.deprecation = :notify 75 | 76 | # Use default logging formatter so that PID and timestamp are not suppressed. 77 | config.log_formatter = ::Logger::Formatter.new 78 | 79 | # Use a different logger for distributed setups. 80 | # require 'syslog/logger' 81 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 82 | 83 | if ENV["RAILS_LOG_TO_STDOUT"].present? 84 | logger = ActiveSupport::Logger.new(STDOUT) 85 | logger.formatter = config.log_formatter 86 | config.logger = ActiveSupport::TaggedLogging.new(logger) 87 | end 88 | 89 | # Do not dump schema after migrations. 90 | config.active_record.dump_schema_after_migration = false 91 | 92 | # Inserts middleware to perform automatic connection switching. 93 | # The `database_selector` hash is used to pass options to the DatabaseSelector 94 | # middleware. The `delay` is used to determine how long to wait after a write 95 | # to send a subsequent read to the primary. 96 | # 97 | # The `database_resolver` class is used by the middleware to determine which 98 | # database is appropriate to use based on the time delay. 99 | # 100 | # The `database_resolver_context` class is used by the middleware to set 101 | # timestamps for the last write to the primary. The resolver uses the context 102 | # class timestamps to determine how long to wait before reading from the 103 | # replica. 104 | # 105 | # By default Rails will store a last write timestamp in the session. The 106 | # DatabaseSelector middleware is designed as such you can define your own 107 | # strategy for connection switching and pass that into the middleware through 108 | # these configuration options. 109 | # config.active_record.database_selector = { delay: 2.seconds } 110 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 111 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 112 | end 113 | -------------------------------------------------------------------------------- /spec/apps/rails/app/controllers/array_hashes_controller.rb: -------------------------------------------------------------------------------- 1 | class ArrayHashesController < ApplicationController 2 | def nullable 3 | response = { 4 | "users" => [ 5 | { 6 | "label" => "John Doe", 7 | "value" => "john_doe", 8 | "admin" => true 9 | }, 10 | { 11 | "label" => "Jane Doe", 12 | "value" => "jane_doe" 13 | }, 14 | { 15 | "label" => nil, 16 | "value" => "invited", 17 | "invited" => true 18 | }, 19 | ], 20 | } 21 | render json: response 22 | end 23 | 24 | def non_nullable 25 | response = { 26 | "users" => [ 27 | { 28 | "label" => "Jane Doe", 29 | "value" => "jane_doe" 30 | }, 31 | { 32 | "label" => "John Doe", 33 | "value" => "john_doe" 34 | } 35 | ], 36 | } 37 | render json: response 38 | end 39 | 40 | def nested 41 | response = { 42 | "fields" => [ 43 | { 44 | "id" => "country_code", 45 | "options" => [ 46 | { 47 | "id" => "us", 48 | "label" => "United States" 49 | }, 50 | { 51 | "id" => "ca", 52 | "label" => "Canada" 53 | } 54 | ], 55 | "validations" => nil, 56 | "always_nil" => nil 57 | 58 | }, 59 | { 60 | "id" => "region_id", 61 | "options" => [ 62 | { 63 | "id" => 1, 64 | "label" => "New York" 65 | }, 66 | { 67 | "id" => 2, 68 | "label" => "California" 69 | } 70 | ], 71 | "validations" => { 72 | "presence" => true 73 | }, 74 | "always_nil" => nil 75 | } 76 | ] 77 | } 78 | render json: response 79 | end 80 | 81 | def empty_array 82 | response = { 83 | "items" => [] 84 | } 85 | render json: response 86 | end 87 | 88 | def single_item 89 | response = { 90 | "items" => [ 91 | { 92 | "id" => 1, 93 | "name" => "Item 1" 94 | } 95 | ] 96 | } 97 | render json: response 98 | end 99 | 100 | def non_hash_items 101 | response = { 102 | "items" => ["string1", "string2", "string3"] 103 | } 104 | render json: response 105 | end 106 | 107 | def nested_arrays 108 | response = { 109 | "items" => [ 110 | { 111 | "id" => 1, 112 | "tags" => ["ruby", "rails"] 113 | }, 114 | { 115 | "id" => 2, 116 | "tags" => ["python", "django"] 117 | }, 118 | { 119 | "id" => 3, 120 | "tags" => ["javascript"] 121 | } 122 | ] 123 | } 124 | render json: response 125 | end 126 | 127 | def nested_objects 128 | response = { 129 | "items" => [ 130 | { 131 | "id" => 1, 132 | "metadata" => { 133 | "author" => "Alice", 134 | "version" => "1.0" 135 | }, 136 | "actions" => [ 137 | { 138 | "label" => "Duplicate", 139 | "modal" => { 140 | "confirm_action" => { 141 | "label" => "Duplicate", 142 | "endpoint" => nil 143 | } 144 | } 145 | }, 146 | { 147 | "label" => "Edit", 148 | }, 149 | { 150 | "label" => "Something Else Again", 151 | "modal" => { 152 | "confirm_action" => { 153 | "label" => nil, 154 | "endpoint" => nil 155 | } 156 | } 157 | } 158 | ] 159 | }, 160 | { 161 | "id" => 2, 162 | "metadata" => { 163 | "author" => "Bob", 164 | "version" => "2.0", 165 | "reviewed" => true 166 | }, 167 | "actions" => [ 168 | { 169 | "label" => "Duplicate", 170 | "modal" => { 171 | "confirm_action" => { 172 | "label" => "Duplicate", 173 | "endpoint" => nil 174 | } 175 | } 176 | }, 177 | { 178 | "label" => "Edit", 179 | }, 180 | { 181 | "label" => "Something Else Again", 182 | "modal" => { 183 | "confirm_action" => { 184 | "label" => nil, 185 | "endpoint" => nil 186 | } 187 | } 188 | } 189 | ] 190 | }, 191 | { 192 | "id" => 3, 193 | "metadata" => { 194 | "author" => "Charlie" 195 | } 196 | } 197 | ] 198 | } 199 | render json: response 200 | end 201 | 202 | def mixed_types_nested 203 | response = { 204 | "items" => [ 205 | { 206 | "id" => 1, 207 | "config" => { 208 | "port" => 8080, 209 | "host" => "localhost" 210 | }, 211 | "form" => [ 212 | { 213 | "value" => "John Doe", 214 | "options" => [ 215 | {"label" => "John Doe", "value" => "john_doe"}, 216 | {"label" => "Jane Doe", "value" => "jane_doe"} 217 | ] 218 | }, 219 | { 220 | "value" => [], 221 | "options" => { 222 | "endpoint" => "some/endpoint" 223 | } 224 | }, 225 | { 226 | "value" => nil, 227 | "options" => nil 228 | }, 229 | ] 230 | }, 231 | { 232 | "id" => 2, 233 | "config" => { 234 | "port" => "3000", 235 | "host" => "example.com", 236 | "ssl" => true 237 | }, 238 | "form" => nil 239 | } 240 | ] 241 | } 242 | render json: response 243 | end 244 | end 245 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # THIS CHANGELOG IS DEPRECATED!! 2 | 3 | Refer https://github.com/exoego/rspec-openapi/releases instead. 4 | 5 | ## v0.12.0 6 | 7 | - feat: Initial support of complex schema with manually-added `oneOf` 8 | [#174](https://github.com/exoego/rspec-openapi/pull/174) 9 | - chore: Test with Ruby 3.3 and Rails 7.1.x 10 | [#169](https://github.com/exoego/rspec-openapi/pull/169) 11 | 12 | ## v0.11.0 13 | - feat: Allow path-based config overrides 14 | [#162](https://github.com/exoego/rspec-openapi/pull/162) 15 | - enhancement: Sort HTTP methods, response status codes, and contents lexicographically 16 | [#163](https://github.com/exoego/rspec-openapi/pull/163) 17 | - enhancement: Remove parameters that conflict with security schemas 18 | [#166](https://github.com/exoego/rspec-openapi/pull/166) 19 | 20 | ## v0.10.0 21 | - bugfix: Merge parameter data to preserve description in manually edited Openapi spec 22 | [#149](https://github.com/exoego/rspec-openapi/pull/149) 23 | - feat: Add ability to configure which path params to ignore 24 | [#150](https://github.com/exoego/rspec-openapi/pull/150) 25 | - feat: Add custom title 26 | [#147](https://github.com/exoego/rspec-openapi/pull/147) 27 | - feat: Add ability to define custom summary and tags builders 28 | [#148](https://github.com/exoego/rspec-openapi/pull/148) 29 | - enhancement: Sort paths lexicographically so the order of paths is more stable and predictable 30 | [#155](https://github.com/exoego/rspec-openapi/pull/155) 31 | - enhancement: requestBody should not merge requestBody from error examples 32 | [#154](https://github.com/exoego/rspec-openapi/pull/154) 33 | 34 | ## v0.9.0 35 | - bugfix: Fix engine path resolution 36 | [#113](https://github.com/exoego/rspec-openapi/pull/113) 37 | - bugfix: fix multiple uploaded files 38 | [#117](https://github.com/exoego/rspec-openapi/pull/117), [#126](https://github.com/exoego/rspec-openapi/pull/126) 39 | - feat: Add required_request_params to metadata 40 | [#114](https://github.com/exoego/rspec-openapi/pull/114) 41 | - bugfix(minitest): 42 | [#128](https://github.com/exoego/rspec-openapi/pull/128) 43 | - doc(minitest): Add instructions for minitest triggered yaml generation 44 | [#116](https://github.com/exoego/rspec-openapi/pull/116) 45 | - chore: Don't dump records into temporary file 46 | [#127](https://github.com/exoego/rspec-openapi/pull/127) 47 | 48 | ## v0.8.1 49 | - bugfix: Empty `required` array should not be present. 50 | [#111](https://github.com/exoego/rspec-openapi/pull/111) 51 | 52 | ## v0.8.0 53 | - Set `required` in request body and response body 54 | [#95](https://github.com/exoego/rspec-openapi/pull/95), [#98](https://github.com/exoego/rspec-openapi/pull/98) 55 | - Generate OpenAPI with minitest instead of RSpec 56 | [#90](https://github.com/exoego/rspec-openapi/pull/90) 57 | - Generate security schemas via RSpec::OpenAPI.security_schemes 58 | [#84](https://github.com/exoego/rspec-openapi/pull/84) 59 | - Bunch of refactorings 60 | 61 | ## v0.7.2 62 | - $ref enhancements: Support $ref in arbitrary depth 63 | [#82](https://github.com/k0kubun/rspec-openapi/pull/82) 64 | 65 | ## v0.7.1 66 | - $ref enhancements: Auto-generate components referenced in "items" 67 | [#80](https://github.com/k0kubun/rspec-openapi/pull/80) 68 | - Administration 69 | - Setup RuboCop 70 | [#73](https://github.com/k0kubun/rspec-openapi/pull/73) 71 | - Setup CodeQL 72 | [#73](https://github.com/k0kubun/rspec-openapi/pull/73) 73 | - Bump Rails v6.0.3.x to fix bundle failure 74 | [#72](https://github.com/k0kubun/rspec-openapi/pull/72) 75 | 76 | ## v0.7.0 77 | - Generate Response Headers 78 | [#69](https://github.com/k0kubun/rspec-openapi/pull/69) 79 | - Initial support for $ref 80 | [#67](https://github.com/k0kubun/rspec-openapi/pull/67) 81 | - Fixed an empty array is turned into nullable object wrongly 82 | [#70](https://github.com/k0kubun/rspec-openapi/pull/70) 83 | 84 | ## v0.6.1 85 | 86 | * Stabilize the order parameter objects and preserve newer examples 87 | [#59](https://github.com/k0kubun/rspec-openapi/pull/59) 88 | 89 | ## v0.6.0 90 | 91 | * Replace `RSpec::OpenAPI.server_urls` with `RSpec::OpenAPI.servers` 92 | [#60](https://github.com/k0kubun/rspec-openapi/pull/60) 93 | 94 | ## v0.5.1 95 | 96 | * Clarify the version requirement for actionpack 97 | [#62](https://github.com/k0kubun/rspec-openapi/pull/62) 98 | 99 | ## v0.5.0 100 | 101 | * Overwrite fields in an existing schema file instead of leaving all existing fields as is 102 | [#55](https://github.com/k0kubun/rspec-openapi/pull/55) 103 | 104 | ## v0.4.8 105 | 106 | * Fix a bug in nested parameters handling 107 | [#46](https://github.com/k0kubun/rspec-openapi/pull/46) 108 | 109 | ## v0.4.7 110 | 111 | * Add `info` config hash 112 | [#43](https://github.com/k0kubun/rspec-openapi/pull/43) 113 | 114 | ## v0.4.6 115 | 116 | * Fix "No route matched for" error in engine routes 117 | [#38](https://github.com/k0kubun/rspec-openapi/pull/38) 118 | 119 | ## v0.4.5 120 | 121 | * Fix linter issues for `tags` and `summary` 122 | [#40](https://github.com/k0kubun/rspec-openapi/pull/40) 123 | 124 | ## v0.4.4 125 | 126 | * De-duplicate parameters by a combination of `name` and `in` 127 | [#39](https://github.com/k0kubun/rspec-openapi/pull/39) 128 | 129 | ## v0.4.3 130 | 131 | * Allow customizing `schema`, `description`, and `tags` through `:openapi` metadata 132 | [#36](https://github.com/k0kubun/rspec-openapi/pull/36) 133 | 134 | ## v0.4.2 135 | 136 | * Allow using Proc as `RSpec::OpenAPI.path` 137 | [#35](https://github.com/k0kubun/rspec-openapi/pull/35) 138 | 139 | ## v0.4.1 140 | 141 | * Add `RSpec::OpenAPI.example_types` to hook types other than `type: :request`. 142 | [#32](https://github.com/k0kubun/rspec-openapi/pull/32) 143 | 144 | ## v0.4.0 145 | 146 | * Drop `RSpec::OpenAPI.output` introduced in v0.3.20 147 | * Guess whether it's JSON or not by the extension of `RSpec::OpenAPI.path` 148 | 149 | ## v0.3.20 150 | 151 | * Add `RSpec::OpenAPI.output` config to output JSON 152 | [#31](https://github.com/k0kubun/rspec-openapi/pull/31) 153 | 154 | ## v0.3.19 155 | 156 | * Add `server_urls` and `request_headers` configs 157 | [#30](https://github.com/k0kubun/rspec-openapi/pull/30) 158 | 159 | ## v0.3.18 160 | 161 | * Support nested query parameters 162 | [#29](https://github.com/k0kubun/rspec-openapi/pull/29) 163 | 164 | ## v0.3.17 165 | 166 | * Rescue NotImplementedError in the after suite hook as well 167 | [#28](https://github.com/k0kubun/rspec-openapi/pull/28) 168 | 169 | ## v0.3.16 170 | 171 | * Use `media_type` instead of `content_type` for Rails 6.1 172 | [#26](https://github.com/k0kubun/rspec-openapi/pull/26) 173 | * Avoid crashing the after suite hook when it fails to build schema 174 | [#27](https://github.com/k0kubun/rspec-openapi/pull/27) 175 | 176 | ## v0.3.15 177 | 178 | * Fix an error when Content-Disposition header is inline 179 | [#24](https://github.com/k0kubun/rspec-openapi/pull/24) 180 | 181 | ## v0.3.14 182 | 183 | * Avoid an error when an application calls `request.body.read` 184 | [#20](https://github.com/k0kubun/rspec-openapi/pull/20) 185 | 186 | ## v0.3.13 187 | 188 | * Avoid crashing when there's no request made in a spec 189 | [#19](https://github.com/k0kubun/rspec-openapi/pull/19) 190 | 191 | ## v0.3.12 192 | 193 | * Generate `nullable: true` for null fields in schema 194 | [#18](https://github.com/k0kubun/rspec-openapi/pull/18) 195 | 196 | ## v0.3.11 197 | 198 | * Show a filename as an `example` for `ActionDispatch::Http::UploadedFile` 199 | [#17](https://github.com/k0kubun/rspec-openapi/pull/17) 200 | 201 | ## v0.3.10 202 | 203 | * Add `info.version` 204 | [#16](https://github.com/k0kubun/rspec-openapi/pull/16) 205 | 206 | ## v0.3.9 207 | 208 | * Initial support for multipart/form-data 209 | [#12](https://github.com/k0kubun/rspec-openapi/pull/12) 210 | 211 | ## v0.3.8 212 | 213 | * Generate `type: 'number', format: 'float'` instead of `type: 'float'` for Float 214 | [#11](https://github.com/k0kubun/rspec-openapi/issues/11) 215 | 216 | ## v0.3.7 217 | 218 | * Classify tag names and remove controller names from summary in Rails 219 | 220 | ## v0.3.6 221 | 222 | * Fix documents generated by Rails engines 223 | 224 | ## v0.3.5 225 | 226 | * Support finding routes in Rails engines 227 | 228 | ## v0.3.4 229 | 230 | * Generate tags by controller names 231 | [#10](https://github.com/k0kubun/rspec-openapi/issues/10) 232 | 233 | ## v0.3.3 234 | 235 | * Avoid `JSON::ParserError` when a response body is no content 236 | [#9](https://github.com/k0kubun/rspec-openapi/issues/9) 237 | 238 | ## v0.3.2 239 | 240 | * Stop generating format as path parameters in Rails 241 | [#8](https://github.com/k0kubun/rspec-openapi/issues/8) 242 | 243 | ## v0.3.1 244 | 245 | * Add `RSpec::OpenAPI.description_builder` config to dynamically generate a description [experimental] 246 | [#6](https://github.com/k0kubun/rspec-openapi/issues/6) 247 | 248 | ## v0.3.0 249 | 250 | * Initial support of rack-test and non-Rails apps [#5](https://github.com/k0kubun/rspec-openapi/issues/5) 251 | 252 | ## v0.2.2 253 | 254 | * Allow disabling `example` by `RSpec::OpenAPI.enable_example = false` 255 | 256 | ## v0.2.1 257 | 258 | * Generate `example` of request body and path / query params 259 | [#4](https://github.com/k0kubun/rspec-openapi/issues/4) 260 | * Remove a wrapper param created by Rails in request body 261 | [#4](https://github.com/k0kubun/rspec-openapi/issues/4) 262 | 263 | ## v0.2.0 264 | 265 | * Generate `example` of response body [#3](https://github.com/k0kubun/rspec-openapi/issues/3) 266 | 267 | ## v0.1.5 268 | 269 | * Support detecting `float` type [#2](https://github.com/k0kubun/rspec-openapi/issues/2) 270 | 271 | ## v0.1.4 272 | 273 | * Avoid NoMethodError on nil Content-Type 274 | * Include a space between controller and action in summary 275 | 276 | ## v0.1.3 277 | 278 | * Add `RSpec::OpenAPI.comment` configuration 279 | 280 | ## v0.1.2 281 | 282 | * Generate `required: true` for path params [#1](https://github.com/k0kubun/rspec-openapi/issues/1) 283 | 284 | ## v0.1.1 285 | 286 | * Generate a path like `/{id}` instead of `/:id` 287 | 288 | ## v0.1.0 289 | 290 | * Initial release 291 | -------------------------------------------------------------------------------- /spec/apps/rails/doc/smart/openapi.yaml: -------------------------------------------------------------------------------- 1 | # This file is auto-generated by rspec-openapi https://github.com/k0kubun/rspec-openapi 2 | # 3 | # When you write a spec in spec/requests, running the spec with `OPENAPI=1 rspec` will 4 | # update this file automatically. You can also manually edit this file. 5 | --- 6 | openapi: 3.0.3 7 | info: 8 | title: rspec-openapi 9 | version: 1.0.0 10 | description: My beautiful API 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | servers: 15 | - url: http://localhost:3000 16 | paths: 17 | "/no-such-path": 18 | get: 19 | summary: no such api 20 | parameters: [] 21 | responses: 22 | '200': 23 | description: dummy 24 | content: 25 | application/json: 26 | schema: 27 | type: number 28 | example: 29 | - 1 30 | "/tables": 31 | get: 32 | summary: index 33 | tags: 34 | - Table 35 | parameters: 36 | - name: page 37 | in: query 38 | schema: 39 | type: integer 40 | example: 1 41 | - name: per 42 | in: query 43 | schema: 44 | type: integer 45 | example: 10 46 | - name: filter[name] 47 | in: query 48 | schema: 49 | type: object 50 | properties: 51 | name: 52 | type: string 53 | example: 54 | name: Example Table 55 | - name: filter[price] 56 | in: query 57 | schema: 58 | type: object 59 | properties: 60 | price: 61 | type: string 62 | example: 63 | price: '0' 64 | - name: X-Authorization-Token 65 | in: header 66 | required: true 67 | schema: 68 | type: string 69 | example: token 70 | responses: 71 | '200': 72 | description: with flat query parameters 73 | content: 74 | application/json: 75 | schema: 76 | type: array 77 | items: 78 | "$ref": "#/components/schemas/Table" 79 | example: 80 | - id: 1 81 | name: access 82 | description: logs 83 | database: 84 | id: 2 85 | name: production 86 | null_sample: 87 | storage_size: 12.3 88 | created_at: '2020-07-17T00:00:00+00:00' 89 | updated_at: '2020-07-17T00:00:00+00:00' 90 | '401': 91 | description: does not return tables if unauthorized 92 | content: 93 | application/json: 94 | schema: 95 | type: object 96 | properties: 97 | message: 98 | type: string 99 | no_such_field: 100 | type: string 101 | example: This field does not exist in rspec 102 | example: 103 | message: This field should be updated by rspec 104 | post: 105 | summary: This method should be removed!! 106 | tags: 107 | - Table 108 | requestBody: 109 | content: 110 | application/json: 111 | schema: 112 | type: object 113 | properties: 114 | name: 115 | type: string 116 | description: 117 | type: string 118 | database_id: 119 | type: integer 120 | no_such_field_request: 121 | type: boolean 122 | required: 123 | - name 124 | - description 125 | - database_id 126 | - no_such_field_request 127 | example: 128 | name: k0kubun 129 | description: description 130 | database_id: 2 131 | no_such_field_request: true 132 | responses: 133 | '201': 134 | description: returns a table 135 | content: 136 | application/json: 137 | schema: 138 | "$ref": "#/components/schemas/Table" 139 | example: 140 | id: 1 141 | name: access 142 | description: logs 143 | database: 144 | id: 2 145 | name: production 146 | null_sample: 147 | storage_size: 12.3 148 | created_at: '2020-07-17T00:00:00+00:00' 149 | updated_at: '2020-07-17T00:00:00+00:00' 150 | "/tables/{id}": 151 | get: 152 | summary: This filed should be updated by rspec 153 | tags: 154 | - Table 155 | parameters: 156 | - name: id 157 | in: path 158 | required: true 159 | schema: 160 | type: integer 161 | example: 1 162 | responses: 163 | '200': 164 | description: returns a table with changes !!! 165 | content: 166 | application/json: 167 | schema: 168 | type: object 169 | properties: 170 | id: 171 | type: integer 172 | name: 173 | type: string 174 | description: 175 | type: string 176 | database: 177 | discriminator: 178 | propertyName: name 179 | oneOf: 180 | - type: array 181 | items: 182 | type: object 183 | properties: 184 | name: 185 | type: string 186 | host: 187 | type: string 188 | port: 189 | type: integer 190 | user: 191 | type: string 192 | schema: 193 | type: string 194 | - type: object 195 | properties: 196 | host: 197 | type: string 198 | port: 199 | type: integer 200 | user: 201 | type: string 202 | schema: 203 | type: string 204 | required: 205 | - host 206 | - port 207 | - schema 208 | null_sample: 209 | nullable: true 210 | storage_size: 211 | type: number 212 | format: float 213 | no_such_field_response: 214 | type: boolean 215 | created_at: 216 | type: string 217 | updated_at: 218 | type: string 219 | example: 220 | id: 1 221 | name: access 222 | description: logs 223 | no_such_field_response: true 224 | database: 225 | id: 2 226 | name: production 227 | null_sample: 228 | storage_size: 12.3 229 | created_at: '2020-07-17T00:00:00+00:00' 230 | updated_at: '2020-07-17T00:00:00+00:00' 231 | "/users": 232 | post: 233 | summary: Create 234 | tags: 235 | - User 236 | requestBody: 237 | content: 238 | application/json: 239 | schema: 240 | oneOf: 241 | - "$ref": "#/components/schemas/PostUsersRequest" 242 | - type: string 243 | responses: 244 | '201': 245 | description: returns a user 246 | content: 247 | application/json: 248 | schema: 249 | "$ref": "#/components/schemas/User" 250 | "/users/{id}": 251 | get: 252 | responses: 253 | '200': 254 | description: returns a user 255 | content: 256 | application/json: 257 | schema: 258 | "$ref": "#/components/schemas/User" 259 | components: 260 | schemas: 261 | Table: 262 | type: object 263 | properties: 264 | id: 265 | type: integer 266 | # This field should exists in expected 267 | # name: 268 | # type: string 269 | this_field_should_not_exist_in_expected: 270 | type: string 271 | description: 272 | type: string 273 | database: 274 | "$ref": "#/components/schemas/Database" 275 | columns: 276 | type: array 277 | items: 278 | "$ref": "#/components/schemas/Column" 279 | null_sample: 280 | nullable: true 281 | storage_size: 282 | type: number 283 | format: float 284 | created_at: 285 | type: string 286 | updated_at: 287 | type: string 288 | Database: 289 | type: object 290 | description: 'this should be preserved' 291 | properties: 292 | id: 293 | type: integer 294 | description: 'this should be preserved' 295 | # This filed should exists in expected 296 | # name: 297 | # type: string 298 | this_field_should_not_exist_in_expected: 299 | type: string 300 | this_field_should_not_exist_in_expected_2: 301 | "$ref": "#/components/schemas/NoSuchSchemaFound" 302 | SchemaNotInUse: 303 | type: object 304 | description: 'should be deleted' 305 | properties: 306 | id: 307 | type: integer 308 | User: 309 | discriminator: 310 | propertyName: name 311 | oneOf: 312 | - type: object 313 | properties: 314 | name: 315 | type: string 316 | foo: 317 | type: string 318 | bar: 319 | type: string 320 | baz: 321 | type: string 322 | quux: 323 | type: string 324 | - type: object 325 | properties: 326 | name: 327 | type: string 328 | relations: 329 | type: object 330 | properties: 331 | avatar: 332 | "$ref": "#/components/schemas/Avatar" 333 | pets: 334 | type: array 335 | items: 336 | "$ref": "#/components/schemas/Pet" 337 | --------------------------------------------------------------------------------