had 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 8db267458f5bae36246d3f0a3b3b479d94884460
4
+ data.tar.gz: 72b0dd4604f3b8bd240cdf96e459d5ef99047c1c
5
+ SHA512:
6
+ metadata.gz: cdbcd9ff09cc012638b003ffc176dd17d19808b1daff08048837d1919ed3d5350f818d7eba34f4e50b819366da9d10294530441c50d43b11aff1750b0c044935
7
+ data.tar.gz: 3951a608143dffd695a2447514d7baf47f676911009e3806e692526699b0f0a4a8887400a83d7450976efcdac3dcb605f556ced6fe00323bee88f8bf1b8e6983
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ coverage
8
+ doc/
9
+ lib/bundler/man
10
+ pkg
11
+ rdoc
12
+ spec/reports
13
+ test/tmp
14
+ test/version_tmp
15
+ tmp
16
+ .idea
17
+ .rvmrc
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.3.4
data/CHANGELOG.txt ADDED
File without changes
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in had.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2017 nsheremet
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # Had
2
+
3
+ Had is Hanami Api Documentation. Gem generates API documentation from your integration tests written with `rspec` for [Hanami](https://hanamirb.org).
4
+
5
+ This is fork of [`reqres_rspec`](https://github.com/reqres-api/reqres_rspec) gem and worked implemantation for Hanami.
6
+
7
+ ## Installation
8
+
9
+ ### 1) Gem
10
+
11
+ Just add this gem to `Gemfile` of your API Application
12
+
13
+ gem 'had', group: :test
14
+
15
+ And then execute:
16
+
17
+ $ bundle install
18
+
19
+ If necessary, add `require "had"` to your `spec/spec_helper.rb` file
20
+
21
+ ## Usage
22
+
23
+ by default `had` is not active (this may be configured!). To activate it, run `rspec` with
24
+
25
+ `HAD_RUN=1 bundle exec rspec --order=defined`
26
+
27
+ Documentation will be put into your application's `/doc` folder
28
+
29
+ ### Sample controller action
30
+
31
+ ```ruby
32
+ # @description creates Category from given parameters
33
+ # description text may be multiline
34
+ # @param category[title] required String Category title
35
+ # @param category[weight] in which order Category will be shown
36
+ # param text may also be multiline
37
+ # @method GET
38
+ # @path /example/path
39
+ # @host example.com
40
+ def call(params)
41
+ # action code
42
+ end
43
+ ```
44
+
45
+ Description param text is started with `@description` and may be multiline.
46
+ Each param text is started with `@param` and first word will be param name, then optionally `required`, then optionally type (`Integer`, `String` etc), and finally param description, which may be multiline as well.
47
+
48
+ ### Sample rspec test
49
+
50
+ ```ruby
51
+ it 'validates params', :skip_had do
52
+ ...
53
+ end
54
+
55
+ context 'With valid params' do
56
+ it 'bakes pie' do
57
+ ...
58
+ end
59
+ end
60
+
61
+ context 'With invalid params', :skip_had do
62
+ it 'returns errors' do
63
+ ...
64
+ end
65
+ end
66
+ ```
67
+
68
+ By default all examples will be added to docs. A context of examples (`context` and `describe` blocks) or any particular examples may be excluded from docs with option `:skip_had`
69
+
70
+ Doc will use full example description, as a title for each separate spec
71
+
72
+ If you want to group examples in another way, you can do something like:
73
+
74
+ ```ruby
75
+ describe 'Something', had_section: 'Foo' do
76
+ context 'valid params', had_title: 'Bakes Pie' do
77
+ it 'works' do
78
+ ...
79
+ end
80
+
81
+ it 'tires baker', had_title: 'Tires baker' do
82
+ ...
83
+ end
84
+ end
85
+ end
86
+ ```
87
+
88
+ ## Configuration
89
+
90
+ ```ruby
91
+ Had.configure do |c|
92
+ c.templates_path = './spec/support/reqres/templates' # Path to custom templates
93
+ c.output_path = 'some path' # by default it will use doc/reqres
94
+ c.formatters = %w(MyCustomFormatter) # List of custom formatters, these can be inherited from Had::Formatters::HTML
95
+ c.title = 'My API Documentation' # Title for your documentation
96
+ end
97
+ ```
98
+
99
+ ## Custom Formatter example
100
+
101
+ ```ruby
102
+ class CustomAPIDoc < Had::Formatters::HTML
103
+ private
104
+ def write
105
+ # Copy assets
106
+ %w(styles images components scripts).each do |folder|
107
+ FileUtils.cp_r(path(folder), output_path)
108
+ end
109
+
110
+ # Generate general pages
111
+ @pages = {
112
+ 'index.html' => 'Introduction',
113
+ 'authentication.html' => 'Authentication',
114
+ 'filtering.html' => 'Filtering, Sorting and Pagination',
115
+ 'locations.html' => 'Locations',
116
+ 'files.html' => 'Files',
117
+ 'external-ids.html' => 'External IDs',
118
+ }
119
+
120
+ @pages.each do |filename, _|
121
+ @current_page = filename
122
+ save filename, render("pages/#{filename}")
123
+ end
124
+
125
+ # Generate API pages
126
+ @records.each do |record|
127
+ @record = record
128
+ @current_page = @record[:filename]
129
+ save "#{record[:filename]}.html", render('spec.html.erb')
130
+ end
131
+ end
132
+ end
133
+ ```
134
+
135
+ 1. Fork it
136
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
137
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
138
+ 4. Push to the branch (`git push origin my-new-feature`)
139
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/had.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'had/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'had'
8
+ spec.version = Had::VERSION
9
+ spec.authors = ['nsheremet']
10
+ spec.email = ['nazariisheremet@gmail.com']
11
+ spec.description = %q{Had is Hanami Api Documentation gem. This gem generates API documentation for integration tests written with RSpec for Hanami}
12
+ spec.summary = %q{Generates API documentation for integration tests written with RSpec for Hanami}
13
+ spec.homepage = 'https://github.com/nsheremet/had'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_dependency 'coderay'
22
+ spec.add_dependency 'mime-types'
23
+
24
+ spec.add_development_dependency 'bundler', '~> 1.3'
25
+ spec.add_development_dependency 'rake'
26
+ end
@@ -0,0 +1,337 @@
1
+ module Had
2
+ class Collector
3
+ # Contains spec values read from rspec example, request and response
4
+ attr_accessor :records
5
+
6
+ # Param importances
7
+ PARAM_IMPORTANCES = %w[required optional conditional deprecated]
8
+
9
+ # Param types
10
+ # NOTE: make sure sub-strings go at the end
11
+ PARAM_TYPES = ['Boolean', 'Text', 'Float', 'DateTime', 'Date', 'File', 'UUID', 'Hash',
12
+ 'Array of Integer', 'Array of String', 'Array', 'Integer', 'String',
13
+ 'Email']
14
+
15
+ # Exclude replacement in symbolized path
16
+ EXCLUDE_PARAMS = %w[limit offset format description controller action]
17
+
18
+ # response headers contain many unnecessary information,
19
+ # everything from this list will be stripped
20
+ EXCLUDE_RESPONSE_HEADER_PATTERNS = %w[
21
+ Cache-Control
22
+ ETag
23
+ X-Content-Type-Options
24
+ X-Frame-Options
25
+ X-Request-Id
26
+ X-Runtime
27
+ X-UA-Compatible
28
+ X-XSS-Protection
29
+ Vary
30
+ Last-Modified
31
+ ]
32
+
33
+ # request headers contain many unnecessary information,
34
+ # everything that match items from this list will be stripped
35
+ EXCLUDE_REQUEST_HEADER_PATTERNS = %w[
36
+ action_controller.
37
+ action_dispatch
38
+ CONTENT_LENGTH
39
+ HTTP_COOKIE
40
+ HTTP_HOST
41
+ HTTP_ORIGIN
42
+ HTTP_USER_AGENT
43
+ HTTPS
44
+ ORIGINAL_FULLPATH
45
+ ORIGINAL_SCRIPT_NAME
46
+ PATH_INFO
47
+ QUERY_STRING
48
+ rack.
49
+ raven.requested_at
50
+ RAW_POST_DATA
51
+ REMOTE_ADDR
52
+ REQUEST_METHOD
53
+ REQUEST_URI
54
+ ROUTES_
55
+ SCRIPT_NAME
56
+ SERVER_NAME
57
+ SERVER_PORT
58
+ sinatra.commonlogger
59
+ sinatra.route
60
+ HTTP_X_API
61
+ warden
62
+ devise.mapping
63
+ ]
64
+
65
+ def initialize
66
+ self.records = []
67
+ end
68
+
69
+ # collects spec data for further processing
70
+ def collect(spec, example, request, response)
71
+ # TODO: remove boilerplate code
72
+ return if request.nil? || response.nil? || !defined?(request.params.env)
73
+
74
+ description = query_parameters = backend_parameters = 'not available'
75
+ params = []
76
+
77
+ if request.params.env && (request_params = request.params)
78
+ action = request.class.name.split('::').last
79
+ controller = request.class.name.split('::')[-2]
80
+ description, additional_info = get_action_description(controller, action)
81
+ # description = request.class.to_s
82
+ params = get_action_params(controller, action)
83
+ query_parameters = request_params.to_h.reject { |p| %w[controller action format].include? p }
84
+ backend_parameters = request_params.to_h.reject { |p| !%w[controller action format].include? p }
85
+ end
86
+
87
+ ex_gr = spec.class.example.metadata[:example_group]
88
+ section = ex_gr[:description]
89
+ while !ex_gr.nil? do
90
+ section = ex_gr[:description]
91
+ ex_gr = ex_gr[:parent_example_group]
92
+ end
93
+
94
+ self.records << {
95
+ filename: prepare_filename_for(spec.class.metadata),
96
+ group: spec.class.metadata[:had_section] || section,
97
+ title: example_title(spec, example),
98
+ description: description,
99
+ params: params,
100
+ request: {
101
+ host: additional_info['host'],
102
+ url: additional_info['host'] + additional_info['path'],
103
+ path: additional_info['path'],
104
+ symbolized_path: additional_info['host'] + additional_info['path'],
105
+ method: additional_info['method'],
106
+ query_parameters: query_parameters,
107
+ backend_parameters: backend_parameters,
108
+ body: request.instance_variable_get("@_body"),
109
+ content_length: query_parameters.to_s.size,
110
+ content_type: request.content_type,
111
+ headers: read_request_headers(request),
112
+ accept: (request.accept rescue nil)
113
+ },
114
+ response: {
115
+ code: response.first,
116
+ body: response.last.last,
117
+ headers: read_response_headers(response),
118
+ format: format(response)
119
+ }
120
+ }
121
+
122
+ # cleanup query params
123
+ begin
124
+ body_hash = JSON.parse(self.records.last[:request][:body])
125
+ query_hash = self.records.last[:request][:query_parameters]
126
+ diff = Hash[*((query_hash.size > body_hash.size) ? query_hash.to_a - body_hash.to_a : body_hash.to_a - query_hash.to_a).flatten]
127
+ self.records.last[:request][:query_parameters] = diff
128
+ rescue
129
+ end
130
+ end
131
+
132
+ def prepare_filename_for(metadata)
133
+ description = metadata[:description]
134
+ example_group = if metadata.key?(:example_group)
135
+ metadata[:example_group]
136
+ else
137
+ metadata[:parent_example_group]
138
+ end
139
+
140
+ if example_group
141
+ [prepare_filename_for(example_group), description].join('/')
142
+ else
143
+ description
144
+ end.downcase.gsub(/[\W]+/, '_').gsub('__', '_').gsub(/^_|_$/, '')
145
+ end
146
+
147
+ # sorts records alphabetically
148
+ def sort
149
+ self.records.sort! do |x,y|
150
+ comp = x[:request][:symbolized_path] <=> y[:request][:symbolized_path]
151
+ comp.zero? ? (x[:title] <=> y[:title]) : comp
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def example_title(spec, example)
158
+ t = prepare_description(example.metadata, :had_title) ||
159
+ spec.class.example.full_description
160
+ t.strip
161
+ end
162
+
163
+ def prepare_description(payload, key)
164
+ payload[key] &&
165
+ ->(x) { (x.is_a?(TrueClass) || x == '') ? payload[:description] : x }.call(payload[key])
166
+ end
167
+
168
+ # read and cleanup response headers
169
+ # returns Hash
170
+ def read_response_headers(response)
171
+ raw_headers = response[1]
172
+ headers = {}
173
+ EXCLUDE_RESPONSE_HEADER_PATTERNS.each do |pattern|
174
+ raw_headers = raw_headers.reject { |h| h if h.start_with? pattern }
175
+ end
176
+ raw_headers.each do |key, val|
177
+ headers.merge!(cleanup_header(key) => val)
178
+ end
179
+ headers
180
+ end
181
+
182
+ def format(response)
183
+ case response[1]['Content-Type']
184
+ when %r{text/html}
185
+ :html
186
+ when %r{application/json}
187
+ :json
188
+ else
189
+ :json
190
+ end
191
+ end
192
+
193
+ # read and cleanup request headers
194
+ # returns Hash
195
+ def read_request_headers(request)
196
+ headers = {}
197
+ request.params.env.keys.each do |key|
198
+ if EXCLUDE_REQUEST_HEADER_PATTERNS.all? { |p| !key.to_s.start_with? p }
199
+ headers.merge!(cleanup_header(key) => request.params.env[key])
200
+ end
201
+ end
202
+ headers
203
+ end
204
+
205
+ # replace each first occurrence of param's value in the request path
206
+ #
207
+ # Example:
208
+ # request path = /api/users/123
209
+ # id = 123
210
+ # symbolized path => /api/users/:id
211
+ #
212
+ def get_symbolized_path(request)
213
+ request_path = request.env['REQUEST_URI'] || request.path
214
+ request_params =
215
+ request.env['action_dispatch.request.parameters'] ||
216
+ request.env['rack.request.form_hash'] ||
217
+ request.env['rack.request.query_hash']
218
+
219
+ if request_params
220
+ request_params
221
+ .except(*EXCLUDE_PARAMS)
222
+ .select { |_, value| value.is_a?(String) }
223
+ .each { |key, value| request_path.sub!("/#{value}", "/:#{key}") if value.to_s != '' }
224
+ end
225
+
226
+ request_path
227
+ end
228
+
229
+ # returns action comments taken from controller file
230
+ # example TODO
231
+ def get_action_comments(controller, action)
232
+ lines = File.readlines(File.join(Had.root, 'app', 'controllers', "#{controller}", "#{action}.rb"))
233
+
234
+ action_line = nil
235
+ lines.each_with_index do |line, index|
236
+ if line.match(/\s*def call/)
237
+ action_line = index
238
+ break
239
+ end
240
+ end
241
+
242
+ if action_line
243
+ comment_lines = []
244
+ request_additionals = {}
245
+ was_comment = true
246
+ while action_line > 0 && was_comment
247
+ action_line -= 1
248
+
249
+ if lines[action_line].match(/\s*#/)
250
+ comment_lines << lines[action_line].strip
251
+ else
252
+ was_comment = false
253
+ end
254
+ end
255
+
256
+ comment_lines.reverse
257
+ else
258
+ ['not found']
259
+ end
260
+ rescue Errno::ENOENT
261
+ ['not found']
262
+ end
263
+
264
+ # returns description action comments
265
+ # example TODO
266
+ def get_action_description(controller, action)
267
+ comment_lines = get_action_comments(controller, action)
268
+ info = {}
269
+ description = []
270
+ comment_lines.each_with_index do |line, index|
271
+ if line.match(/\s*#\s*@description/) # @description blah blah
272
+ description << line.gsub(/\A\s*#\s*@description/, '').strip
273
+ comment_lines[(index + 1)..-1].each do |multiline|
274
+ if !multiline.match(/\s*#\s*@param/)
275
+ description << "\n"
276
+ description << multiline.gsub(/\A\s*#\s*/, '').strip
277
+ else
278
+ break
279
+ end
280
+ end
281
+ elsif line.match(/\s*# @method/)
282
+ info['method'] = line.split(' ').last
283
+ elsif line.match(/\s*# @path/)
284
+ info['path'] = line.split(' ').last
285
+ elsif line.match(/\s*# @host/)
286
+ info['host'] = line.split(' ').last
287
+ end
288
+ end
289
+
290
+ [description.join(' '), info]
291
+ end
292
+
293
+ # returns params action comments
294
+ # example TODO
295
+ def get_action_params(controller, action)
296
+ comment_lines = get_action_comments(controller, action)
297
+
298
+ comments_raw = []
299
+ has_param = false
300
+ comment_lines.each do |line|
301
+ if line.match(/\s*#\s*@param/) # @param id required Integer blah blah
302
+ has_param = true
303
+ comments_raw << ''
304
+ end
305
+ if has_param
306
+ line = line.gsub(/\A\s*#\s*@param/, '')
307
+ line = line.gsub(/\A\s*#\s*/, '').strip
308
+
309
+ comments_raw.last << "\n" unless comments_raw.last.empty?
310
+ comments_raw.last << line
311
+ end
312
+ end
313
+
314
+ comments = []
315
+ comments_raw.each do |comment|
316
+ match_data = comment.match(/(?<name>[a-z0-9A-Z_\[\]]+)?\s*(?<required>#{PARAM_IMPORTANCES.join('|')})?\s*(?<type>#{PARAM_TYPES.join('|')})?\s*(?<description>.*)/m)
317
+
318
+ if match_data
319
+ comments << {
320
+ name: match_data[:name],
321
+ required: match_data[:required],
322
+ type: match_data[:type],
323
+ description: match_data[:description]
324
+ }
325
+ else
326
+ comments << { description: comment }
327
+ end
328
+ end
329
+
330
+ comments
331
+ end
332
+
333
+ def cleanup_header(key)
334
+ key.to_s.sub(/^HTTP_/, '').underscore.split('_').map(&:capitalize).join('-')
335
+ end
336
+ end
337
+ end
@@ -0,0 +1,52 @@
1
+ require 'pry'
2
+ require 'fileutils'
3
+
4
+ module Had
5
+ extend self
6
+
7
+ def configure
8
+ yield configuration
9
+ end
10
+
11
+ def configuration
12
+ @configuration ||= Configuration.new
13
+ end
14
+
15
+ def logger
16
+ Logger.new(STDOUT)
17
+ end
18
+
19
+ def root
20
+ configuration.root
21
+ end
22
+
23
+ class Configuration
24
+ DEFAULT_FORMATTERS = %w(html json)
25
+
26
+ def initialize
27
+ Had.logger.level = Logger::INFO
28
+
29
+ if ENV['APP_ROOT'].nil?
30
+ raise 'APP_ROOT is not defined'
31
+ else
32
+ @root = ENV['APP_ROOT']
33
+ end
34
+
35
+ @templates_path = File.expand_path('../templates', __FILE__)
36
+ @output_path = File.join(@root, '/doc/had')
37
+ FileUtils.mkdir_p @output_path
38
+
39
+ requested_formats = (ENV['HAD_FORMATTERS'].to_s).split(',')
40
+ requested_formats.sort_by!{|fmt| [DEFAULT_FORMATTERS.index(fmt), fmt]}
41
+ @formatters = requested_formats.empty? ? %w(html) : requested_formats
42
+
43
+ @title = 'Hanami Api Documentation'
44
+ end
45
+
46
+ attr_accessor :templates_path
47
+ attr_accessor :output_path
48
+ attr_accessor :title
49
+ attr_accessor :formatters
50
+ attr_reader :root
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ module Had
2
+ module Formatters
3
+ class Base
4
+ def initialize(records)
5
+ @records = records
6
+ @output_path = Had.configuration.output_path
7
+ @logger = Had.logger
8
+ end
9
+ attr_reader :logger, :output_path, :records
10
+
11
+ def process
12
+ cleanup
13
+ write
14
+ end
15
+
16
+ private
17
+
18
+ def write
19
+ raise 'Not Implemented'
20
+ end
21
+
22
+ def cleanup_pattern
23
+ '**/*'
24
+ end
25
+
26
+ def cleanup
27
+ unless Dir.exist?(output_path)
28
+ FileUtils.mkdir_p(output_path)
29
+ logger.info "#{output_path} was recreated"
30
+ end
31
+ FileUtils.rm_rf(Dir.glob("#{output_path}/#{cleanup_pattern}"), secure: true)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,45 @@
1
+ require 'coderay'
2
+
3
+ module Had
4
+ module Formatters
5
+ class HTML < Base
6
+ private
7
+ def write
8
+ files = {
9
+ 'rspec_doc_table_of_content.html' => 'header.erb',
10
+ 'index.html' => 'index.erb',
11
+ 'panel.html' => 'panel.erb'
12
+ }
13
+
14
+ files.each { |filename, template| save(filename, render(template)) }
15
+
16
+ @records.each do |record|
17
+ @record = record
18
+ save "rspec_doc_#{record[:filename]}.html", render('spec.erb')
19
+ end
20
+ end
21
+
22
+ def cleanup_pattern
23
+ '*.html'
24
+ end
25
+
26
+ def path(filename)
27
+ File.join(Had.configuration.templates_path, filename)
28
+ end
29
+
30
+ def render(filename, arguments = {})
31
+ eval <<-RUBY
32
+ #{ arguments.map {|k, v| "#{k} = #{v}"}.join("\n") }
33
+ ERB.new(File.open(path(filename)).read).result(binding)
34
+ RUBY
35
+ rescue Exception => e
36
+ logger.error "Reqres::Formatters::HTML.render exception #{e.message}"
37
+ end
38
+
39
+ def save(filename, data)
40
+ File.write(File.join(output_path, filename), data)
41
+ logger.info "Reqres::Formatters::HTML saved #{path(filename)}"
42
+ end
43
+ end
44
+ end
45
+ end