api_blueprint 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: 3c06b5d206b8bf0f3ca2d9540ce47da6200ff8c5
4
+ data.tar.gz: 1979c9532714e356234d04858b37b437629d674a
5
+ SHA512:
6
+ metadata.gz: ff4835d306e95aa438ef3cfeaed9e777c3600e0beaaa6f0eb3b5569a544060b19c0c4ed4879eac9bc17bf1e47385c87f9618237c6a837cfb2833e5a5b8e8dd32
7
+ data.tar.gz: 7811e24b5dfdd4912635ca738657e0c888b12520ae4b5a7cd6f616dd2ca075cfed70dd730c36c275d252075cdd157e454c3ae557be7d2ec250a1872fe1c7342b
data/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # api-blueprint
2
+
3
+ Semi-automatic solution for creating Rails app's API documentation. Here's how it works:
4
+
5
+ 1. You start with method list generated from RSpec request specs. For each method, you get a list of parameters and examples.
6
+ 2. Then, you can extend it in whatever way you need using Markdown syntax. You can organize documentation files into partials.
7
+ 3. Upon any API change, like serializer change that changes responses, you can update automatically generated parts of docs.
8
+ 4. Once done, you can compile your documentation into single, nicely styled HTML file. You can also auto-deploy it via SSH.
9
+
10
+ ## Installation
11
+
12
+ Add to `Gemfile`:
13
+
14
+ ```ruby
15
+ gem 'api_blueprint', group: [:development, :test]
16
+ ```
17
+
18
+ Then run:
19
+
20
+ bundle install
21
+
22
+ Add the following inside `RSpec.configure` block in `spec/spec_helper.rb`:
23
+
24
+ ```ruby
25
+ config.include ApiBlueprint::Collect::SpecHook
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ **api-blueprint** consists of two modules:
31
+
32
+ 1. **Collect** module: allows to run RSpec request suite in order to auto-generate API method information.
33
+ 2. **Compile** module: allows to turn whole Markdown documentation into single, ready-to-publish HTML file.
34
+
35
+ ### Collect
36
+
37
+ In order to auto-generate API method information you should invoke:
38
+
39
+ rake blueprint:collect
40
+
41
+ > By default, all specs inside `spec/requests/api` are run. You can configure that by creating a [Blueprintfile](#configuration) configuration.
42
+
43
+ This will generate the `doc/api.md` file with a Markdown documentation ready to compile. If this file already exists, **api-blueprint** will not override it. It will write to `tmp/merge.md` instead so you can merge both existing and generated documentation manually in whatever way you want.
44
+
45
+ Of course, it's just a starting point and you should at least fill in some resource, action and parameter descriptions. But that's a story for the **Compile** module.
46
+
47
+ #### Regenerate examples
48
+
49
+ You get the RSpec-based example listing for every auto-generated API method documentation. There's a chance that
50
+
51
+ ### Compile
52
+
53
+ In order to turn your documentation into ready-to-publish HTML file you should invoke:
54
+
55
+ rake blueprint:compile
56
+
57
+ This will create the final `doc/api.html`. You can deploy this file to configured SSH target with:
58
+
59
+ rake blueprint:deploy
60
+
61
+ If you want to preview this file constantly when editing Markdown docs, you can do so with:
62
+
63
+ rake blueprint:watch
64
+
65
+ > You should add `doc/**/*.html` to your `.gitignore` as there's no need to clutter your project history with compiled HTML that you can easily recreate on demand.
66
+
67
+ #### Require another file
68
+
69
+ You can split your documentation into separate files and directories in order to organize it better and reuse same fragments in multiple places. You can do that with the following Markdown:
70
+
71
+ ```md
72
+ <require:fragments/deprecation_warning.md>
73
+ ```
74
+
75
+ ## Configuration
76
+
77
+ Configuration for **api-blueprint** lives in `Blueprintfile` inside application directory. It's basically a listing of documentations governed by **api-blueprint**, each with a set of options. It looks like this:
78
+
79
+ ```yaml
80
+ api:
81
+ spec: "spec/requests/api/v2"
82
+ blueprint: "doc/api.md"
83
+ html: "doc/api.html"
84
+ deploy: "user@server.com:/home/app/public/api.html"
85
+ naming:
86
+ sessions:
87
+ create: "Sign In"
88
+ ```
89
+
90
+ Here's what specific per-documentation options stand for:
91
+
92
+ Option | Description
93
+ -------|------------
94
+ `spec` | RSpec spec suite directory
95
+ `blueprint` | Main documentation file (Markdown)
96
+ `html` | Target HTML file created after compilation
97
+ `deploy` | SSH address used for documentation deployment
98
+ `naming` | Dictionary of custom API method names
99
+
100
+ First group is always a default one. You can switch any rake task to work on other group by specifying its name with `rake blueprint:collect group=other`.
@@ -0,0 +1,92 @@
1
+ module ApiBlueprint::Collect::ControllerHook
2
+ def self.included(base)
3
+ return unless ENV['API_BLUEPRINT_DUMP'] == '1'
4
+
5
+ base.around_filter :dump_blueprint_around
6
+ end
7
+
8
+ class Parser
9
+ attr_reader :input
10
+
11
+ def initialize(input)
12
+ @input = input
13
+ end
14
+
15
+ def method
16
+ input.method.to_s.upcase
17
+ end
18
+
19
+ def params
20
+ JSON.parse(input.params.reject do |k,_|
21
+ ['action', 'controller'].include?(k)
22
+ end.to_json)
23
+ end
24
+
25
+ def headers
26
+ Hash[input.headers.env.select do |k, v|
27
+ (k.start_with?("HTTP_X_") || k == 'ACCEPT') && v
28
+ end.map do |k, v|
29
+ [human_header_key(k), v]
30
+ end]
31
+ end
32
+
33
+ def body
34
+ if input.content_type == 'application/json'
35
+ if input.body != 'null'
36
+ JSON.parse(input.body)
37
+ else
38
+ ""
39
+ end
40
+ else
41
+ input.body
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def human_header_key(key)
48
+ key.sub("HTTP_", '').split("_").map do |x|
49
+ x.downcase
50
+ end.join("_")
51
+ end
52
+ end
53
+
54
+ def dump_blueprint_around
55
+ yield
56
+ ensure
57
+ dump_blueprint
58
+ end
59
+
60
+ def dump_blueprint
61
+ file = ApiBlueprint::Collect::Storage.request_dump
62
+ in_parser = Parser.new(request)
63
+ out_parser = Parser.new(response)
64
+
65
+ data = {
66
+ 'request' => {
67
+ 'path' => request.path,
68
+ 'method' => in_parser.method,
69
+ 'params' => in_parser.params,
70
+ 'headers' => in_parser.headers,
71
+ 'content_type' => request.content_type,
72
+ 'accept' => request.accept
73
+ },
74
+ 'response' => {
75
+ 'status' => response.status,
76
+ 'content_type' => response.content_type,
77
+ 'body' => out_parser.body
78
+ },
79
+ 'route' => {
80
+ 'controller' => controller_name,
81
+ 'action' => action_name
82
+ }
83
+ }
84
+
85
+ spec = ApiBlueprint::Collect::Storage.spec_dump
86
+ if File.exists?(spec)
87
+ data['spec'] = YAML::load_file(spec)
88
+ end
89
+
90
+ File.write(file, data.to_yaml)
91
+ end
92
+ end
@@ -0,0 +1,334 @@
1
+ class ApiBlueprint::Collect::Merge
2
+ attr_reader :target, :renderer, :preprocessor
3
+
4
+ def initialize(options)
5
+ @target = options[:target]
6
+ @logger = options[:logger]
7
+
8
+ @renderer = ApiBlueprint::Collect::Renderer.new
9
+ @preprocessor = ApiBlueprint::Collect::Preprocessor.new(
10
+ :naming => options[:naming])
11
+ end
12
+
13
+ def merge
14
+ log "Merging into '#{@target}'..."
15
+
16
+ File.write(target, body_content)
17
+ end
18
+
19
+ def update_examples
20
+ log "Updating examples in '#{@target}'..."
21
+ log ''
22
+
23
+ library.each do |resource, actions|
24
+ actions.each do |action, info|
25
+ log "#{resource}: #{action}\n\n"
26
+
27
+ info[:requests].each do |request|
28
+ insert = find_insertion_point(resource, action, request[:title])
29
+
30
+ if insert && insert[1] == nil
31
+ insert_example_block(insert, request)
32
+ elsif insert
33
+ replace_example_block(insert, request)
34
+ else
35
+ log "no insertion point: #{request_title request}", :error
36
+ end
37
+ end
38
+
39
+ log ''
40
+ end
41
+ end
42
+ end
43
+
44
+ def clear_examples
45
+ log "Clearing examples in '#{@target}'...\n\n"
46
+
47
+ library.each do |resource, actions|
48
+ actions.each do |action, info|
49
+ clear = find_clear_point(resource, action)
50
+
51
+ log "#{resource}: #{action}\n\n"
52
+
53
+ if clear
54
+ clear_example_block(clear)
55
+ else
56
+ log "no clear point", :error
57
+ end
58
+
59
+ log ''
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ # library loading
67
+
68
+ def library
69
+ @library ||= begin
70
+ resources = {}
71
+
72
+ requests.each do |request|
73
+ resource = (resources[preprocessor.resource_name(request)] ||= {})
74
+ action = (resource[preprocessor.action_name(request)] ||= {
75
+ :requests => []
76
+ })
77
+
78
+ action[:requests] << request
79
+ end
80
+
81
+ resources.each do |resource, actions|
82
+ actions.each do |action, info|
83
+ preprocessor.preprocess(info)
84
+ end
85
+ end
86
+
87
+ resources = resources.sort
88
+
89
+ resources
90
+ end
91
+ end
92
+
93
+ def requests
94
+ ApiBlueprint::Collect::Storage.request_dumps.collect do |file|
95
+ YAML::load_file(file)
96
+ end.uniq
97
+ end
98
+
99
+ # content assembly
100
+
101
+ def body_content
102
+ library.collect do |resource, actions|
103
+ text = renderer.resource_header(resource)
104
+
105
+ text += actions.collect do |action, info|
106
+ text = renderer.action_header(action)
107
+
108
+ text += renderer.description_header
109
+ text += renderer.signature(info[:path], info[:method])
110
+ text += renderer.parameter_table(info[:params])
111
+ text += examples(info)
112
+ end.join
113
+ end.join
114
+ end
115
+
116
+ def examples(info)
117
+ text = renderer.examples_header
118
+
119
+ text += info[:requests].collect do |request|
120
+ example(request)
121
+ end.join
122
+ end
123
+
124
+ def example(request)
125
+ text = renderer.example_header(request[:title])
126
+
127
+ if request[:request_headers].present?
128
+ text += renderer.example_subheader(:request_headers) +
129
+ renderer.code_block(request[:request_headers])
130
+ end
131
+
132
+ if request[:params]
133
+ text += renderer.example_subheader(:request_params) +
134
+ renderer.code_block(request[:params])
135
+ end
136
+
137
+ text += renderer.example_subheader(:response_headers) +
138
+ renderer.code_block(request[:response_headers]) +
139
+ renderer.example_subheader(:response_body) +
140
+ renderer.code_block(request[:body])
141
+ end
142
+
143
+ # example updating
144
+
145
+ def find_insertion_point(resource, action, example)
146
+ @partial_map = build_partial_map(@target)
147
+
148
+ resource = find_chapter(1, "Resource: #{resource}")
149
+ return nil unless resource
150
+
151
+ action = find_chapter(2, "Action: #{action}", resource)
152
+ return nil unless action
153
+
154
+ examples = find_chapter(3, "Examples:", action)
155
+ return nil unless examples
156
+
157
+ example = find_chapter(4, "Example: #{example}", examples)
158
+
159
+ if example
160
+ example
161
+ else
162
+ [examples[1], nil]
163
+ end
164
+ end
165
+
166
+ def find_clear_point(resource, action)
167
+ @partial_map = build_partial_map(@target)
168
+
169
+ resource = find_chapter(1, "Resource: #{resource}")
170
+ return nil unless resource
171
+
172
+ action = find_chapter(2, "Action: #{action}", resource)
173
+ return nil unless action
174
+
175
+ examples = find_chapter(3, "Examples:", action)
176
+ return nil unless examples
177
+
178
+ [examples[0] + 2, examples[1]]
179
+ end
180
+
181
+ def find_chapter(level, title, constraints = nil)
182
+ from = constraints ? constraints[0] : 0
183
+ to = constraints ? constraints[1] : @partial_map.length - 1
184
+
185
+ point = []
186
+
187
+ @partial_map[from..to].each_with_index do |line, index|
188
+ map_index = from + index
189
+
190
+ if line[0].strip == (('#' * level) + ' ' + title)
191
+ point[0] = map_index
192
+ elsif point[0].present? && line[0].match(/^\#{1,#{level}}\s/)
193
+ point[1] = map_index - 1
194
+
195
+ break
196
+ end
197
+ end
198
+
199
+ if point[0]
200
+ point[1] ||= to
201
+
202
+ point
203
+ else
204
+ nil
205
+ end
206
+ end
207
+
208
+ def insert_example_block(insert, request)
209
+ insert_map = @partial_map[insert[0]]
210
+
211
+ file = insert_map[1]
212
+ at = insert_map[2]
213
+
214
+ log "#{file} @ #{at}: #{request_title request}", :add
215
+
216
+ e = example(request).split("\n").map { |l| l + "\n" }
217
+
218
+ lines = File.readlines(file)
219
+ if lines[at].present?
220
+ lines.insert(at + 1, "\n")
221
+ at += 1
222
+ end
223
+ if lines[at + 1].present?
224
+ lines.insert(at + 1, "\n")
225
+ end
226
+ lines = lines[0..at] + e + lines[at + 1..-1]
227
+
228
+ File.write(file, lines.join(''))
229
+ end
230
+
231
+ def replace_example_block(insert, request)
232
+ from_map = @partial_map[insert[0]]
233
+ to_map = @partial_map[insert[1]]
234
+
235
+ unless from_map[1] == to_map[1]
236
+ return log("[!] multi-file range: #{request_title request}")
237
+ end
238
+
239
+ file = from_map[1]
240
+ from = from_map[2] - 1
241
+ to = to_map[2] + 1
242
+
243
+ log "#{file} @ #{from}-#{to}: #{request_title request}", :modify
244
+
245
+ e = example(request).split("\n").map { |l| l + "\n" }
246
+
247
+ lines = File.readlines(file)
248
+ if lines[to].present?
249
+ lines.insert(to, "\n")
250
+ end
251
+ lines = lines[0..from] + e + lines[to..-1]
252
+
253
+ File.write(file, lines.join(''))
254
+ end
255
+
256
+ def clear_example_block(clear)
257
+ from_map = @partial_map[clear[0]]
258
+ to_map = @partial_map[clear[1]]
259
+
260
+ unless from_map and to_map
261
+ return log('wrong mapping', :error)
262
+ end
263
+
264
+ unless from_map[1] == to_map[1]
265
+ return log("multi-file range", :error)
266
+ end
267
+
268
+ file = from_map[1]
269
+ from = from_map[2] - 1
270
+ to = to_map[2] + 1
271
+
272
+ lines = File.readlines(file)
273
+ if lines[from].blank? && lines[to].blank?
274
+ to += 1
275
+ end
276
+
277
+ log "#{file} @ #{from}-#{to}", :remove
278
+
279
+ final_lines = lines[0..from]
280
+ final_lines += lines[to..-1] if lines[to..-1]
281
+
282
+ File.write(file, final_lines.join(''))
283
+ end
284
+
285
+ def build_partial_map(file)
286
+ lines = File.readlines(file)
287
+ map = []
288
+
289
+ lines.each_with_index do |line, index|
290
+ if line.start_with?("<require:")
291
+ filename = line.split('<require:')[1].split('>')[0] + '.md'
292
+ path = file.split('/')[0..-2].join('/')
293
+
294
+ map += build_partial_map(path + '/' + filename)
295
+ else
296
+ map << [line, file, index]
297
+ end
298
+ end
299
+
300
+ map
301
+ end
302
+
303
+ def request_title(request)
304
+ limit = 60
305
+ title = request[:title].strip
306
+
307
+ if title.length < limit + 2
308
+ title
309
+ else
310
+ title[0..limit] + '(...)'
311
+ end
312
+ end
313
+
314
+ # other
315
+
316
+ def log(message, kind = nil)
317
+ if @logger.to_s == 'stdout'
318
+ message = case kind.to_s
319
+ when 'error'
320
+ (' ! ' + message).colorize(:red)
321
+ when 'add'
322
+ (' + ' + message).colorize(:green)
323
+ when 'modify'
324
+ (' * ' + message).colorize(:magenta)
325
+ when 'remove'
326
+ (' - ' + message).colorize(:cyan)
327
+ else
328
+ message
329
+ end
330
+
331
+ puts message
332
+ end
333
+ end
334
+ end
@@ -0,0 +1,175 @@
1
+ class ApiBlueprint::Collect::Preprocessor
2
+ attr_reader :naming
3
+
4
+ def initialize(options = {})
5
+ @naming = options[:naming]
6
+ end
7
+
8
+ def resource_name(request)
9
+ request['route']['controller'].singularize.split('_').map do |word|
10
+ word.camelize
11
+ end.join(' ').pluralize
12
+ end
13
+
14
+ def action_name(request)
15
+ model = resource_name(request)
16
+ prefix = %w{a e i o u}.include?(model[0].downcase) ? 'an' : 'a'
17
+
18
+ if (n = naming) && (n = n[model.underscore]) && (n = n[request['route']['action']])
19
+ return n
20
+ end
21
+
22
+ case request['route']['action']
23
+ when 'index'
24
+ "List all #{model.pluralize}"
25
+ when 'show'
26
+ "Retrieve single #{model.singularize}"
27
+ when 'update'
28
+ "Update an existing #{model.singularize}"
29
+ when 'destroy'
30
+ "Remove an existing #{model.singularize}"
31
+ else
32
+ request['route']['action'].humanize + " #{prefix} #{model.singularize}"
33
+ end
34
+
35
+ # request['route']['action'].humanize
36
+ end
37
+
38
+ def preprocess(info)
39
+ any_request = info[:requests].first
40
+
41
+ info[:path] = any_request['request']['path'].sub(/\d+$/, '{id}')
42
+ info[:method] = any_request['request']['method']
43
+ info[:params] = collect_request_params(info[:requests])
44
+
45
+ info[:requests].each do |request|
46
+ preprocess_request(request)
47
+ end
48
+
49
+ unique_requests = []
50
+ info[:requests].each do |request|
51
+ unique_requests.reject! do |existing_request|
52
+ existing_request[:title] == request[:title]
53
+ end
54
+
55
+ unique_requests.push(request)
56
+ end
57
+ info[:requests] = unique_requests
58
+ end
59
+
60
+ private
61
+
62
+ def collect_request_params(requests)
63
+ merged_params = {}
64
+ requests.sort_by do |request|
65
+ -request['response']['status']
66
+ end.each do |request|
67
+ merged_params.deep_merge!(request['request']['params'])
68
+ end
69
+
70
+ merged_params = Hash[merged_params.select { |k,v| ! v.is_a?(Hash) || v.any? }]
71
+
72
+ collect_merged_params(merged_params)
73
+ end
74
+
75
+ def collect_merged_params(merged_params)
76
+ params = {}
77
+ merged_params.each do |param, value|
78
+ if value.is_a?(Hash)
79
+ if value['original_filename'].present?
80
+ params[param] = {
81
+ :type => 'file',
82
+ :example => value['original_filename']
83
+ }
84
+ else
85
+ params[param] = {
86
+ :type => 'nested',
87
+ :params => collect_merged_params(value)
88
+ }
89
+ end
90
+ elsif value.is_a?(Array)
91
+ items = value.collect { |i| collect_merged_params(i) }
92
+
93
+
94
+ params[param] = {
95
+ :type => 'array',
96
+ :params => items.inject(&:merge)
97
+ }
98
+ else
99
+ if value == true || value == false
100
+ type = 'boolean'
101
+ value = value ? 'true' : 'false'
102
+ elsif value.to_i.to_s == value
103
+ type = 'integer'
104
+ elsif value.to_f.to_s == value
105
+ type = 'decimal'
106
+ value = value.to_f.round(6).to_s
107
+ else
108
+ type = 'string'
109
+ end
110
+
111
+ params[param] = {
112
+ :type => type,
113
+ :example => value
114
+ }
115
+ end
116
+ end
117
+
118
+ params
119
+ end
120
+
121
+ def clear_files(params)
122
+ p = {}
123
+
124
+ params.each do |key, value|
125
+ if value.is_a?(Hash)
126
+ if value['original_filename'].present?
127
+ p[key] = "file <#{value['original_filename']}>"
128
+ else
129
+ p[key] = clear_files(value)
130
+ end
131
+ else
132
+ p[key] = value
133
+ end
134
+ end
135
+
136
+ p
137
+ end
138
+
139
+ def preprocess_request(request)
140
+ if request['request']['params'].present?
141
+ params = Hash[request['request']['params'].select { |k, v| ! v.is_a?(Hash) || v.any?}]
142
+ params = clear_files(params)
143
+ request[:params] = JSON.pretty_generate(params) if params.any?
144
+ end
145
+
146
+ if request['response']['body'].is_a?(Hash)
147
+ request[:body] = JSON.pretty_generate(request['response']['body'])
148
+ else
149
+ request[:body] = request['response']['body']
150
+ end
151
+
152
+ request[:request_headers] = preprocess_headers({
153
+ # 'Accept' => request['request']['accept'],
154
+ 'Content-Type' => request['request']['content_type']
155
+ }.merge(request['request']['headers']).select { |_, v| v.present? })
156
+
157
+ request[:response_headers] = preprocess_headers({
158
+ 'Status' => request['response']['status'],
159
+ 'Content-Type' => request['response']['content_type']
160
+ })
161
+
162
+ request[:title] = request['spec']['title_parts'][1..-1].join(' ')
163
+ request[:title] = request[:title][0].upcase + request[:title][1..-1]
164
+ end
165
+
166
+ def preprocess_headers(headers)
167
+ header_key_length = headers.collect do |key, _|
168
+ key.length
169
+ end.max
170
+
171
+ headers.collect do |key, value|
172
+ "#{key.split("_").map(&:camelize).join("-")}:#{' ' * (header_key_length - key.length)} #{value}"
173
+ end.join("\n")
174
+ end
175
+ end
@@ -0,0 +1,70 @@
1
+ class ApiBlueprint::Collect::Renderer
2
+ def parameter_table(params, level = 0)
3
+ text = ''
4
+
5
+ if level == 0
6
+ text += "#### Parameters:\n\n"
7
+ text += "Name | Type | Description\n"
8
+ text += "-----|------|---------|------------\n"
9
+ end
10
+
11
+ params.each do |name, info|
12
+ comment = ''
13
+ comment = "Params for each #{name.singularize}:" if info[:type] == 'array'
14
+
15
+ text += "#{'[]' * level} #{name} | *#{info[:type]}*#{info[:example].present? ? " `Example: #{info[:example]}`" : ''} | #{comment}\n"
16
+
17
+ if info[:type] == 'nested' || info[:type] == 'array'
18
+ text += parameter_table(info[:params], level + 1)
19
+ end
20
+ end
21
+ text += "\n" if level == 0
22
+
23
+ # text += "#### Parameters:\n\n" if level == 0
24
+ # text += params.collect do |name, info|
25
+ # if info[:type] == 'nested'
26
+ # "#{' ' * (level * 2)}- **#{name}**\n" +
27
+ # parameter_table(info[:params], level + 1)
28
+ # else
29
+ # "#{' ' * (level * 2)}- **#{name}** (#{info[:type]}, `#{info[:example]}`)"
30
+ # end
31
+ # end.join("\n")
32
+ # text += "\n\n" if level == 0
33
+
34
+ text
35
+ end
36
+
37
+ def resource_header(content)
38
+ "# Resource: #{content}\n\n"
39
+ end
40
+
41
+ def action_header(content)
42
+ "## Action: #{content}\n\n"
43
+ end
44
+
45
+ def description_header
46
+ "### Description:\n\n"
47
+ end
48
+
49
+ def signature(url, method)
50
+ "#### Signature:\n\n**#{method}** `#{url}`\n\n"
51
+ end
52
+
53
+ def examples_header
54
+ "### Examples:\n\n"
55
+ end
56
+
57
+ def example_header(content)
58
+ "#### Example: #{content}\n\n"
59
+ end
60
+
61
+ def example_subheader(content)
62
+ content = content.to_s.humanize + ':' if content.is_a?(Symbol)
63
+
64
+ "##### #{content}\n\n"
65
+ end
66
+
67
+ def code_block(content)
68
+ content.split("\n").collect { |line| " " * 4 + line }.join("\n") + "\n\n"
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ module ApiBlueprint::Collect::SpecHook
2
+ def self.included(base)
3
+ return unless ENV['API_BLUEPRINT_DUMP'] == '1'
4
+
5
+ base.before(:each) do |example|
6
+ data = {
7
+ 'title_parts' => example_description_parts(example)
8
+ }
9
+
10
+ File.write(ApiBlueprint::Collect::Storage.spec_dump, data.to_yaml)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def example_description_parts(example)
17
+ parts = []
18
+ parts << example.metadata[:description_args].join(' ')
19
+ at = example.metadata[:example_group]
20
+
21
+ while at && at[:description_args]
22
+ parts << at[:description_args].join(' ')
23
+ at = at[:parent_example_group]
24
+ end
25
+
26
+ parts.reverse!
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ module ApiBlueprint::Collect::Storage
2
+ def self.request_dumps
3
+ Dir[Rails.root.join('tmp', 'api_blueprint_request_*.yml').to_s]
4
+ end
5
+
6
+ def self.spec_dump
7
+ Rails.root.join('tmp', 'api_blueprint_spec.yml')
8
+ end
9
+
10
+ def self.request_dump
11
+ Rails.root.join('tmp',
12
+ "api_blueprint_request_#{(Time.now.to_f * 1000).to_i}_#{sprintf("%09d", rand(1e9))}.yml")
13
+ end
14
+ end
@@ -0,0 +1,220 @@
1
+ class ApiBlueprint::Compile::Compile
2
+ attr_reader :source, :target, :partials
3
+
4
+ def initialize(options)
5
+ @source = options[:source]
6
+ @target = options[:target]
7
+ @logger = options[:logger]
8
+
9
+ @partials = []
10
+ end
11
+
12
+ def compile
13
+ layout = load_layout
14
+ insert_content(layout)
15
+ insert_stylesheets(layout)
16
+
17
+ layout_html = layout.to_html
18
+ insert_javascripts(layout_html)
19
+
20
+ layout_doc = load_document(layout_html)
21
+ insert_title(layout_doc)
22
+ insert_host(layout_doc)
23
+ insert_copyright(layout_doc)
24
+
25
+ write_html(layout_doc.to_html)
26
+ end
27
+
28
+ private
29
+
30
+ def load_layout
31
+ log "Rendering '#{@source}' within the blueprint layout..."
32
+
33
+ Nokogiri::HTML(File.read(ApiBlueprint::Compile::Storage.index_html))
34
+ end
35
+
36
+ def load_document(html)
37
+ Nokogiri::HTML(html)
38
+ end
39
+
40
+ def write_html(layout_html)
41
+ return unless @target
42
+
43
+ File.write(@target, layout_html)
44
+
45
+ log "Wrote #{@title_text || "document"} into '#{@target}'"
46
+ end
47
+
48
+ def insert_content(layout)
49
+ container = layout.at_css("#blueprint-document")
50
+
51
+ if container
52
+ content = render_markdown(@source)
53
+ content = prepend_toc(content)
54
+
55
+ container.add_child(content)
56
+ end
57
+
58
+ log " - compiled #{(container/'h1').length} chapter(s)"
59
+ end
60
+
61
+ def insert_stylesheets(layout)
62
+ style = layout.at_css("#blueprint-style")
63
+
64
+ if style
65
+ text = ApiBlueprint::Compile::Storage.stylesheets.collect do |file|
66
+ Sass::Engine.new(File.read(file), :syntax => :scss, :style => :compressed).render
67
+ end.join("\n\n")
68
+
69
+ style.add_child(text)
70
+
71
+ log " - compiled #{ApiBlueprint::Compile::Storage.stylesheets.count} stylesheet(s)"
72
+ end
73
+ end
74
+
75
+ def insert_javascripts(layout_html)
76
+ return unless layout_html.include?('<script id="blueprint-script"></script>');
77
+
78
+ text = ApiBlueprint::Compile::Storage.javascripts.collect do |file|
79
+ Uglifier.compile(File.read(file))
80
+ end.join("\n\n")
81
+
82
+ layout_html.sub!('<script id="blueprint-script"></script>',
83
+ '<script id="blueprint-script">' + text + '</script>')
84
+
85
+ log " - compiled #{ApiBlueprint::Compile::Storage.stylesheets.count} javascript(s)"
86
+ end
87
+
88
+ def insert_title(doc)
89
+ title_node = doc.at('p:contains("Title: ")')
90
+
91
+ if title_node
92
+ @title_text = title_node.text.strip.sub("Title: ", '')
93
+ title_tag = doc.at('title')
94
+
95
+ title_node['id'] = 'title'
96
+ title_node.content = @title_text
97
+
98
+ if title_tag
99
+ title_tag.content = @title_text
100
+ end
101
+ end
102
+ end
103
+
104
+ def insert_host(doc)
105
+ host_node = doc.at('p:contains("Host: ")')
106
+
107
+ if host_node
108
+ @host_text = host_node.text.strip.sub("Host: ", '')
109
+
110
+ host_node['id'] = 'host'
111
+ host_node.content = @host_text
112
+ end
113
+ end
114
+
115
+ def insert_copyright(doc)
116
+ copyright_node = doc.at('p:contains("Copyright: ")')
117
+
118
+ if copyright_node
119
+ copyright_text = copyright_node.text.strip.sub("Copyright: ", '')
120
+ copyright_text = "© #{Date.today.year} #{copyright_text}"
121
+ copyright_tag = doc.at('.copyright')
122
+
123
+ copyright_node['id'] = 'copyright'
124
+ copyright_node.content = copyright_text
125
+
126
+ if copyright_tag
127
+ copyright_tag.content = copyright_text
128
+ end
129
+ end
130
+ end
131
+
132
+ def render_markdown(input_file)
133
+ markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML, :tables => true, :no_intra_emphasis => true)
134
+ content = markdown.render(File.read(input_file))
135
+ doc = Nokogiri::HTML(content)
136
+ requires = (doc/'a:contains("require:")')
137
+
138
+ requires.each do |require_link|
139
+ file = require_link['href'].split(':').last
140
+ file = input_file.split('/')[0..-2].join('/') + '/' + file + '.md'
141
+ file = render_markdown(file)
142
+
143
+ require_link.after(file)
144
+ require_link.remove
145
+ end
146
+
147
+ partials << input_file unless partials.include?(input_file)
148
+
149
+ doc.to_html
150
+ end
151
+
152
+ def prepend_toc(doc)
153
+ doc = Nokogiri::HTML(doc)
154
+ headers = []
155
+
156
+ (doc/"h1").each_with_index do |header, header_index|
157
+ subheaders = []
158
+ header['id'] = header.text.sub('Resource: ', '').parameterize
159
+ headers << { :title => header.text.sub('Resource: ', ''), :id => header['id'], :subheaders => subheaders }
160
+
161
+ next_all(header, :where => "h2", :until => 'h1').each_with_index do |action_header, action_index|
162
+ action_header['id'] = header['id'] + "-" + action_header.text.sub('Action: ', '').parameterize
163
+ subheaders << { :title => action_header.text.sub('Action: ', ''), :id => action_header['id'] }
164
+ end
165
+ end
166
+
167
+ toc = "<h1>Table Of Contents</h1>"
168
+ toc += "<ul>"
169
+
170
+ headers.collect do |header|
171
+ toc += "<li>"
172
+ toc += "<a href='##{header[:id]}'>#{header[:title]}</a>"
173
+
174
+ if header[:subheaders].any?
175
+ toc += "<ul>"
176
+ header[:subheaders].each do |subheader|
177
+ toc += "<li><a href='##{subheader[:id]}'>#{subheader[:title]}</a></li>"
178
+ end
179
+ toc += "</ul>"
180
+ end
181
+
182
+ toc += '</li>'
183
+ end
184
+
185
+ toc += "</ul>"
186
+
187
+ doc.at('h1').before(toc)
188
+
189
+ doc.to_html
190
+ end
191
+
192
+ def next_all(element, options = {})
193
+ doc = element.document
194
+
195
+ selector = options[:where]
196
+ rejector = options[:until]
197
+ results = Nokogiri::XML::NodeSet.new(doc)
198
+ element = element.next_element
199
+
200
+ while element
201
+ set = Nokogiri::XML::NodeSet.new(doc, [element])
202
+
203
+ if rejector && (set/rejector).any?
204
+ break
205
+ elsif ! selector || (set/selector).any?
206
+ results << element
207
+ end
208
+
209
+ element = element.next_element
210
+ end
211
+
212
+ return results
213
+ end
214
+
215
+ def log(message)
216
+ if @logger.to_s == 'stdout'
217
+ puts message
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,17 @@
1
+ module ApiBlueprint::Compile::Storage
2
+ def self.root_join(*parts)
3
+ File.join(File.dirname(__FILE__), *parts)
4
+ end
5
+
6
+ def self.index_html
7
+ root_join('assets', 'index.html')
8
+ end
9
+
10
+ def self.javascripts
11
+ Dir[root_join('assets', '*.js').to_s]
12
+ end
13
+
14
+ def self.stylesheets
15
+ Dir[root_join('assets', '*.scss').to_s]
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ module ApiBlueprint
2
+ class Railtie < Rails::Railtie
3
+ railtie_name :api_blueprint
4
+
5
+ initializer "api_blueprint.action_controller" do
6
+ ActiveSupport.on_load(:action_controller) do
7
+ include ApiBlueprint::Collect::ControllerHook
8
+ end
9
+ end
10
+
11
+ rake_tasks do
12
+ load 'tasks/blueprint.rake'
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module ApiBlueprint
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,16 @@
1
+ module ApiBlueprint
2
+ Collect = Module.new
3
+ Compile = Module.new
4
+ end
5
+
6
+ require 'redcarpet'
7
+ require 'api_blueprint/collect/controller_hook'
8
+ require 'api_blueprint/collect/merge'
9
+ require 'api_blueprint/collect/preprocessor'
10
+ require 'api_blueprint/collect/renderer'
11
+ require 'api_blueprint/collect/spec_hook'
12
+ require 'api_blueprint/collect/storage'
13
+ require 'api_blueprint/compile/compile'
14
+ require 'api_blueprint/compile/storage'
15
+ require 'api_blueprint/railtie'
16
+ require 'api_blueprint/version'
@@ -0,0 +1,140 @@
1
+ def blueprintfile(opts = {})
2
+ file = Rails.root.join("Blueprintfile")
3
+
4
+ if File.exists?(file)
5
+ file = YAML.load_file(file)
6
+
7
+ if ENV['group']
8
+ hash = file[ENV['group']] || {}
9
+ else
10
+ hash = file.any? ? file.first[1] : {}
11
+ end
12
+ else
13
+ hash = {}
14
+ end
15
+
16
+ if opts[:write_blueprint] != false && hash['blueprint'].present? && File.exists?(hash['blueprint'])
17
+ hash.delete('blueprint')
18
+ end
19
+
20
+ ['spec', 'blueprint', 'html'].each do |param|
21
+ hash[param] = ENV[param] if ENV[param].present?
22
+ end
23
+
24
+ hash
25
+ end
26
+
27
+ def compile(source, target)
28
+ compiler = ApiBlueprint::Compile::Compile.new(:source => source, :target => target, :logger => :stdout)
29
+ compiler.compile
30
+
31
+ compiler
32
+ end
33
+
34
+ def regenerate_dumps
35
+ Rake::Task["blueprint:collect:clear"].execute
36
+ puts
37
+ Rake::Task["blueprint:collect:generate"].execute
38
+ puts
39
+ end
40
+
41
+ namespace :blueprint do
42
+ desc 'Clear, generate and merge dumps for specified request spec(s)'
43
+ task :collect => :environment do
44
+ regenerate_dumps
45
+
46
+ Rake::Task["blueprint:collect:merge"].execute
47
+ end
48
+
49
+ namespace :collect do
50
+ desc 'Remove all generated request dumps'
51
+ task :clear => :environment do
52
+ files = ApiBlueprint::Collect::Storage.request_dumps
53
+
54
+ puts "Clearing #{files.count} request dumps..."
55
+
56
+ File.unlink(*files)
57
+ end
58
+
59
+ desc 'Generate request dumps for specified request spec(s)'
60
+ task :generate => :environment do
61
+ args = blueprintfile['spec'] || "spec/requests/#{ENV['group'] || 'api'}"
62
+ opts = { :order => 'default', :format => 'documentation' }
63
+ cmd = "API_BLUEPRINT_DUMP=1 bundle exec rspec #{opts.map{|k,v| "--#{k} #{v}"}.join(' ')} #{args}"
64
+
65
+ puts "Invoking '#{cmd}'..."
66
+
67
+ system(cmd)
68
+ end
69
+
70
+ desc 'Merge all existing request dumps into single blueprint'
71
+ task :merge => :environment do
72
+ target = blueprintfile['blueprint'] || Rails.root.join('tmp', 'merge.md')
73
+
74
+ ApiBlueprint::Collect::Merge.new(:target => target, :logger => :stdout, :naming => blueprintfile['naming']).merge
75
+ end
76
+ end
77
+
78
+ namespace :examples do
79
+ desc 'Clear existing examples in blueprint'
80
+ task :clear => :environment do
81
+ target = blueprintfile(:write_blueprint => false)['blueprint'] || Rails.root.join('tmp', 'merge.md')
82
+
83
+ ApiBlueprint::Collect::Merge.new(:target => target, :logger => :stdout).clear_examples
84
+ end
85
+
86
+ desc 'Uuse dumps to update examples in blueprint'
87
+ task :update => :environment do
88
+ target = blueprintfile(:write_blueprint => false)['blueprint'] || Rails.root.join('tmp', 'merge.md')
89
+
90
+ ApiBlueprint::Collect::Merge.new(:target => target, :logger => :stdout).update_examples
91
+ end
92
+
93
+ desc 'Use dumps to replace examples in blueprint'
94
+ task :replace => :environment do
95
+ target = blueprintfile(:write_blueprint => false)['blueprint'] || Rails.root.join('tmp', 'merge.md')
96
+
97
+ ApiBlueprint::Collect::Merge.new(:target => target, :logger => :stdout).clear_examples
98
+ ApiBlueprint::Collect::Merge.new(:target => target, :logger => :stdout).update_examples
99
+ end
100
+ end
101
+
102
+ desc 'Compile the blueprint into complete HTML documentation'
103
+ task :compile => :environment do
104
+ source = blueprintfile(:write_blueprint => false)['blueprint'] || Rails.root.join('tmp', 'merge.md')
105
+ target = blueprintfile(:write_blueprint => false)['html'] || source.to_s.sub(/\.md$/, '.html')
106
+
107
+ compile(source, target)
108
+ end
109
+
110
+ desc 'Watch for changes in the blueprint and compile it into HTML on every change'
111
+ task :watch => :environment do
112
+ source = blueprintfile(:write_blueprint => false)['blueprint'] || Rails.root.join('tmp', 'merge.md')
113
+ target = blueprintfile(:write_blueprint => false)['html'] || source.to_s.sub(/\.md$/, '.html')
114
+
115
+ files = compile(source, target).partials
116
+
117
+ FileWatcher.new(files).watch do |filename|
118
+ puts "\n--- #{Time.now} [#{filename.split('/').last}] ---\n\n"
119
+ compile(source, target)
120
+ end
121
+ end
122
+
123
+ desc 'Deploy the HTML documentation on remote target'
124
+ task :deploy => :environment do
125
+ Rake::Task["blueprint:compile"].execute
126
+
127
+ source = blueprintfile(:write_blueprint => false)['html']
128
+ target = blueprintfile(:write_blueprint => false)['deploy']
129
+
130
+ if source.present? && target.present?
131
+ cmd = "scp -q #{source} #{target}"
132
+
133
+ puts "\nDeploying to '#{target}'..."
134
+
135
+ system(cmd)
136
+ end
137
+ end
138
+ end
139
+
140
+
metadata ADDED
@@ -0,0 +1,133 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: api_blueprint
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Karol Słuszniak
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-10-27 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: filewatcher
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: nokogiri
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rails
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: redcarpet
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rspec-rails
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ description: You start with method list generated from RSpec request specs. For each
84
+ method, you get a list of parameters and examples. Then, you can extend it in whatever
85
+ way you need using Markdown syntax. You can organize documentation files into partials.
86
+ Upon any API change, like serializer change that changes responses, you can update
87
+ automatically generated parts of docs. Once done, you can compile your documentation
88
+ into single, nicely styled HTML file. You can also auto-deploy it via SSH.
89
+ email: k.sluszniak@visuality.pl
90
+ executables: []
91
+ extensions: []
92
+ extra_rdoc_files:
93
+ - README.md
94
+ files:
95
+ - README.md
96
+ - lib/api_blueprint.rb
97
+ - lib/api_blueprint/collect/controller_hook.rb
98
+ - lib/api_blueprint/collect/merge.rb
99
+ - lib/api_blueprint/collect/preprocessor.rb
100
+ - lib/api_blueprint/collect/renderer.rb
101
+ - lib/api_blueprint/collect/spec_hook.rb
102
+ - lib/api_blueprint/collect/storage.rb
103
+ - lib/api_blueprint/compile/compile.rb
104
+ - lib/api_blueprint/compile/storage.rb
105
+ - lib/api_blueprint/railtie.rb
106
+ - lib/api_blueprint/version.rb
107
+ - lib/tasks/blueprint.rake
108
+ homepage: http://github.com/visualitypl/api-blueprint
109
+ licenses:
110
+ - MIT
111
+ metadata: {}
112
+ post_install_message:
113
+ rdoc_options: []
114
+ require_paths:
115
+ - lib
116
+ required_ruby_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ requirements: []
127
+ rubyforge_project:
128
+ rubygems_version: 2.2.2
129
+ signing_key:
130
+ specification_version: 4
131
+ summary: Semi-automatic solution for creating Rails app's API documentation based
132
+ on RSpec request specs.
133
+ test_files: []