Skip to content

Instantly share code, notes, and snippets.

@rdnewman
Created August 29, 2020 21:18
Show Gist options
  • Save rdnewman/ba5143f1598eff72b2cba48ce78ca43d to your computer and use it in GitHub Desktop.
Save rdnewman/ba5143f1598eff72b2cba48ce78ca43d to your computer and use it in GitHub Desktop.
Rswag support for multiple examples (could eventually be a complementary gem to rswag)
# /spec/support/documentation/after_rspec_example.rb
# Normally, we'd want to break into multiple files, but then it becomes more to install (unless gemified)
module SomeApp
module Documentation
class AfterRspecExample
def self.apply!(context, example, response)
SwaggerExample::Request.apply!(context, example, response)
SwaggerExample::Response.apply!(context, example, response)
end
# Handles automatically updating Rswag's Swagger output with examples
module SwaggerExample
class BaseWriter
def self.apply!(context, example, response)
new(context, example, response).apply!
end
attr_reader :context, :example
def initialize(rspec_example_context, rspec_example, rspec_response)
@context = rspec_example_context
@example = rspec_example
@response = Received::Response.new(rspec_response)
end
def apply!
return unless example
return unless response
before_first_example!
add_example!
end
private
attr_reader :response
# when first RSpec example collected, build empty structure for
# Swagger's multiple payload/response example support
def before_first_example!
return unless content?
return if example.metadata.dig(*target_keys).present?
target_keys.inject(example.metadata) do |node, key|
node[key] ||= {}
end.merge!(initial_structure)
end
# empty structure that can receive Swagger examples for any used mime-type
def initial_structure
mime_list = Array(example.metadata[:operation][mimetype_basis])
mime_list.each_with_object({}) do |mime_type, hsh|
hsh[mime_type] ||= { examples: {} }
end
end
def example_description
@example_description ||= example.metadata[:example_group][:description]
end
def add_example!
# top-level method to drive how to add the example to metadata for Swagger
raise "must implement ##{__method__} in subclass"
end
def content?
# define how to tell if the example has relevant content
raise "must implement ##{__method__} in subclass"
end
def description
# define description to be displayed in Swagger
raise "must implement ##{__method__} in subclass"
end
def mimetype_basis
# define which Rswag key defines mime types for these Swagger examples
raise "must implement ##{__method__} in subclass"
end
def target_keys
# define metadata keys under which Swagger examples should be parked
raise "must implement ##{__method__} in subclass"
end
end
class Response < BaseWriter
private
def content?
response.content?
end
def mimetype_basis
:produces
end
def target_keys
[:response, :content]
end
def description
@description ||= example_description
end
# add detail from response to set of Rswag response examples under the mime type
def add_example!
return unless response.content?
target = example.metadata.dig(*target_keys)
target[response.mime_type][:examples].merge!(
response.content(description)
)
end
end
class Request < BaseWriter
private
attr_reader :request
def before_first_example!
@request = Received::Request.new(context, example)
super
end
def content?
request.content?
end
def mimetype_basis
:consumes
end
def target_keys
[:operation, :request_examples]
end
def description
@description ||= "[#{example.metadata[:response][:code]}] #{example_description}"
end
def add_example!
add_paths!
add_body!
end
def add_paths!
request.path_parameters.each_key { |key| add_path!(key) }
end
def add_path!(key)
param = example.metadata[:operation][:parameters].detect do |parameter|
request.path?(parameter) && (parameter[:name] == key)
end
return unless param
param[:schema][:example] ||= request.path_parameters[key]
end
# add detail from request to set of Rswag payload examples under the mime type
def add_body!
return unless request.content?
target = example.metadata.dig(*target_keys)
return unless target
# TODO: what if the mime_type of the request should differ from the response?
target[response.mime_type][:examples].merge!(
request.body_content(description)
)
end
end
end
# These simplify pulling data from what was received from the RSpec example
module Received
class Request
def initialize(rspec_context, rspec_example)
@context = rspec_context
@example = rspec_example
end
def content?
!for_body.empty?
end
def body_content(description)
return {} unless content?
{ description => { value: for_body.first } }
end
def path_parameters
return @path_parameters if @path_parameters
return (@path_parameters ||= {}) if ignore_request?
@path_parameters = relevant_path_parameters.each_with_object({}) do |param, hsh|
key = param[:name]
hsh[key] = context.public_send(key) if context.respond_to?(key)
end
end
def path?(parameter)
parameter[:in] == :path && parameter[:schema]
end
private
attr_reader :context, :example
def for_body
return @for_body if @for_body
return (@for_body ||= []) if ignore_request?
@for_body = relevant_body_parameters.map do |hsh|
context.public_send(hsh[:name]) if context.respond_to?(hsh[:name])
end.compact
end
def relevant_body_parameters
example.metadata[:operation][:parameters].select do |parameter|
(parameter[:in] == :body || parameter[:in] == :formData) && parameter[:schema]
end
end
def relevant_path_parameters
example.metadata[:operation][:parameters].select do |parameter|
path?(parameter) && parameter[:name]
end
end
def ignore_request?
@ignore_request ||=
!(example.metadata[:example_request].nil? || example.metadata[:example_request])
end
end
class Response
attr_reader :response
def initialize(rspec_response)
@response = rspec_response
end
def content?
response.body.present?
end
def content(description)
return {} unless content?
{ description => { value: JSON.parse(response.body, symbolize_names: true) } }
end
def mime_type
return @mime_type if @mime_type
if response.headers && response.headers['Content-Type']
@mime_type = response.headers['Content-Type'].split(';')&.first
end
@mime_type ||= 'application/vnd.api+json' # default/fallback
end
end
end
end
end
end
# /lib/tasks/rswag_specs_task_custom.rake
require 'rspec/core/rake_task'
namespace :rswag do
namespace :specs do
desc 'Generate Swagger JSON files from [custom] documentation specs'
RSpec::Core::RakeTask.new('document') do |t|
t.pattern = ENV.fetch(
'PATTERN',
'spec/documentation/**/*_spec.rb' # this isn't critical, but I like separating normal request specs from Rswag
)
# special environment variable to control simplecov gem
# [NOT IMPORTANT, BUT THIS PREVENTS ANY MINIMUM COVERAGE ERRORS BEING SIGNALLED WHEN USING SIMPLECOV]
ENV.store('SIMPLECOV_DISABLED_BY_SWAGGER', 'TRUE')
# t.rspec_opts = ['--format Rswag::Specs::SwaggerFormatter', '--order defined']
t.rspec_opts = ['--format SomeApp::Documentation::SwaggerFormatter', '--order defined'] # <-- this is the important line!
end
end
end
task rswag: ['rswag:specs:document']
# [CAN OMIT REMAINDER IF NOT WORRIED ABOUT MINIMUM COVERAGE ERRORS BEING SIGNALLED WHEN USING SIMPLECOV]
# reset environment to remove environment variable added above
task :rswag_specs_document_reset do
ENV.delete('SIMPLECOV_DISABLED_BY_SWAGGER')
end
Rake::Task['rswag:specs:document'].enhance do
Rake::Task[:rswag_specs_document_reset].invoke
end
# /spec/support/documentation/swagger_formatter
# When all methods in here can be removed, we can switch the rake task
# at lib/tasks/rswag_specs_task_custom.rake back to use the standard
# Rswag::Specs::SwaggerFormatter
module SomeApp
module Documentation
class SwaggerFormatter < ::Rswag::Specs::SwaggerFormatter
::RSpec::Core::Formatters.register(self, :example_group_finished, :stop)
###################################################
# Support for enabling multiple response examples.
#
# See https://github.com/rswag/rswag/issues/325
# (also https://github.com/rswag/rswag/blob/master/rswag-specs/lib/rswag/specs/swagger_formatter.rb)
#
# Based on Rswag v2.3.1
#
# TODO: remove when Rswag deployes the fix to its issue #325
def upgrade_content!(mime_list, target_node)
# ORIGINAL LINE... :
# target_node.merge!(content: {})
# ...replaced by CUSTOM LINE:
target_node[:content] ||= {} # CUSTOM: Here we're avoiding "content" key overriding
schema = target_node[:schema]
return if mime_list.empty? || schema.nil?
mime_list.each do |mime_type|
# TODO: upgrade to have content-type specific schema
# ORIGINAL LINE... :
# target_node[:content][mime_type] = { schema: schema }
# ...replaced by CUSTOM LINE (which also appears post v2.3.1):
(target_node[:content][mime_type] ||= {}).merge!(schema: schema)
end
end
###################################################
# Support for enabling multiple request examples.
#
# Intact from original (Rswag v2.3.1) except where noted by CUSTOM LINES below.
#
# TODO: remove when Rswag adds support for multiple request examples
def stop(_notification = nil)
@config.swagger_docs.each do |url_path, doc|
unless doc_version(doc).start_with?('2')
doc[:paths]&.each_pair do |_k, v|
v.each_pair do |_verb, value|
is_hash = value.is_a?(Hash)
if is_hash && value.dig(:parameters)
schema_param = value.dig(:parameters)&.find { |p| (p[:in] == :body || p[:in] == :formData) && p[:schema] }
mime_list = value.dig(:consumes)
if value && schema_param && mime_list
value[:requestBody] = { content: {} } unless value.dig(:requestBody, :content)
mime_list.each do |mime|
value[:requestBody][:content][mime] = { schema: schema_param[:schema] }
end
# CUSTOM LINES [start]
example_set = value.delete(:request_examples)
if example_set
mime_list.each do |mime|
value[:requestBody][:content][mime].merge!(example_set[mime] || {})
end
end
# CUSTOM LINES [end]
end
value[:parameters].reject! { |p| p[:in] == :body || p[:in] == :formData }
end
remove_invalid_operation_keys!(value)
end
end
end
file_path = File.join(@config.swagger_root, url_path)
dirname = File.dirname(file_path)
FileUtils.mkdir_p dirname unless File.exist?(dirname)
File.open(file_path, 'w') do |file|
file.write(pretty_generate(doc))
end
@output.puts "Swagger doc generated at #{file_path}"
end
end
end
end
end
# /spec/swagger_helper.rb
RSpec.configure do |config|
# ...usual rswag configuration goes here...
# Automatic produce Swagger examples from rswag specs
config.after do |example|
# only perform if the SwaggerFormatter is involved (otherwise, assume running normal rspec)
if config.formatters.any? { |f| f.class.name =~ /::SwaggerFormatter$/ }
SomeApp::Documentation::AfterRspecExample.apply!(self, example, response)
end
end
end
@phlegx
Copy link

phlegx commented Sep 11, 2020

Very nice work! Thx. Please contribute to Rswag with pull requests, so that all can use it.

@schmijos
Copy link

💘 Thank you very much for sharing this. I'd also appreciate if this would go upstream.

Instead of relying on the formatter, I use the following method to mark my swagger tests:

RSpec.configure do |config|
  config.formatter = CustomSwaggerFormatter

  config.define_derived_metadata(file_path: %r{spec/swagger}) do |metadata|
    metadata[:swagger] = true
  end

  config.after do |example|
    if example.metadata[:swagger]
      AfterRspecExample.apply!(self, example, response)
    end
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment