Created
August 29, 2020 21:18
-
-
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)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# /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 |
Very nice work! Thx. Please contribute to Rswag with pull requests, so that all can use it.
💘 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
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!)