had 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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