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
@rdnewman
Copy link
Author

rdnewman commented Aug 29, 2020

Will automatically produce not just multiple response examples, but multiple payload (i.e., request) examples and path parameter examples based on actual values produced during RSpec runs.

Tested in August 2020 with Rswag v2.3.1 for use with OpenAPI 3.0.3.

Used w/ Redoc (https://github.com/Redocly/redoc).

Not tested globally so not ready to be a gem. This approach leaves the default Rswag install intact (no monkey patching, separate rake task) except for its use in swagger_helper.rb (which is pretty easily controlled).

Only special config is to use example_request: false on any Rswag response in spec that should not be included in generated payload examples. Otherwise, use as if normal Rswag (other than no longer needing to write out hardcoded examples!)

@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