apiculture 0.0.12
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 +7 -0
- data/Gemfile +15 -0
- data/LICENSE.txt +20 -0
- data/README.md +83 -0
- data/Rakefile +45 -0
- data/apiculture.gemspec +90 -0
- data/lib/apiculture.rb +266 -0
- data/lib/apiculture/action.rb +46 -0
- data/lib/apiculture/action_definition.rb +30 -0
- data/lib/apiculture/app_documentation.rb +54 -0
- data/lib/apiculture/app_documentation_tpl.mustache +103 -0
- data/lib/apiculture/markdown_segment.rb +6 -0
- data/lib/apiculture/method_documentation.rb +130 -0
- data/lib/apiculture/sinatra_instance_methods.rb +36 -0
- data/lib/apiculture/timestamp_promise.rb +6 -0
- data/lib/apiculture/version.rb +3 -0
- data/spec/apiculture/action_spec.rb +45 -0
- data/spec/apiculture/app_documentation_spec.rb +113 -0
- data/spec/apiculture/method_documentation_spec.rb +80 -0
- data/spec/apiculture_spec.rb +263 -0
- data/spec/spec_helper.rb +16 -0
- metadata +226 -0
@@ -0,0 +1,113 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe "Apiculture.api_documentation" do
|
4
|
+
let(:app) {
|
5
|
+
Class.new(Sinatra::Base) do
|
6
|
+
extend Apiculture
|
7
|
+
|
8
|
+
markdown_string 'This API is very important. Because it has to do with pancakes.'
|
9
|
+
|
10
|
+
documentation_build_time!
|
11
|
+
|
12
|
+
desc 'Order a pancake'
|
13
|
+
required_param :diameter, "Diameter of the pancake", Integer
|
14
|
+
param :topping, 'Type of topping', String
|
15
|
+
responds_with 200, 'When the pancake is created succesfully', {id: 'abdef..c21'}
|
16
|
+
api_method :post, '/pancakes' do
|
17
|
+
end
|
18
|
+
|
19
|
+
desc 'Check the pancake status'
|
20
|
+
route_param :id, 'Pancake ID to check status on'
|
21
|
+
responds_with 200, 'When the pancake is found', {status: 'Baking'}
|
22
|
+
responds_with 404, 'When no such pancake exists', {status: 'No such pancake'}
|
23
|
+
api_method :get, '/pancake/:id' do
|
24
|
+
end
|
25
|
+
|
26
|
+
desc 'Throw away the pancake'
|
27
|
+
route_param :id, 'Pancake ID to delete'
|
28
|
+
api_method :delete, '/pancake/:id' do
|
29
|
+
end
|
30
|
+
end
|
31
|
+
}
|
32
|
+
|
33
|
+
it 'generates app documentation as HTML without the body element' do
|
34
|
+
docco = app.api_documentation
|
35
|
+
generated_html = docco.to_html_fragment
|
36
|
+
|
37
|
+
expect(generated_html).not_to include('<body')
|
38
|
+
expect(generated_html).to include('Pancake ID to check status on')
|
39
|
+
expect(generated_html).to include('Pancake ID to delete')
|
40
|
+
end
|
41
|
+
|
42
|
+
it 'generates app documentation in HTML' do
|
43
|
+
docco = app.api_documentation
|
44
|
+
generated_html = docco.to_html
|
45
|
+
|
46
|
+
if ENV['SHOW_TEST_DOC']
|
47
|
+
File.open('t.html', 'w') do |f|
|
48
|
+
f.write(generated_html)
|
49
|
+
f.flush
|
50
|
+
`open #{f.path}`
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
expect(generated_html).to include('<body')
|
55
|
+
expect(generated_html).to include('Pancake ID to check status on')
|
56
|
+
expect(generated_html).to include('When the pancake is created succesfully')
|
57
|
+
expect(generated_html).to include('"id": "abdef..c21"')
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'generates app documentation in Markdown' do
|
61
|
+
docco = app.api_documentation
|
62
|
+
generated_markdown = docco.to_markdown
|
63
|
+
|
64
|
+
expect(generated_markdown).not_to include('<body')
|
65
|
+
expect(generated_markdown).to include('## POST /pancakes')
|
66
|
+
end
|
67
|
+
|
68
|
+
it 'generates app documentation honoring the mount point' do
|
69
|
+
overridden = Class.new(Sinatra::Base) do
|
70
|
+
extend Apiculture
|
71
|
+
mounted_at '/api/v2/'
|
72
|
+
api_method :get, '/pancakes' do
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
generated_markdown = overridden.api_documentation.to_markdown
|
77
|
+
expect(generated_markdown).to include('## GET /api/v2/pancakes')
|
78
|
+
end
|
79
|
+
|
80
|
+
it 'generates app documentation injecting the inline Markdown strings' do
|
81
|
+
app_class = Class.new(Sinatra::Base) do
|
82
|
+
extend Apiculture
|
83
|
+
markdown_string '# This describes important stuff'
|
84
|
+
api_method :get, '/pancakes' do
|
85
|
+
end
|
86
|
+
markdown_string '# This describes even more important stuff'
|
87
|
+
markdown_string 'This is a paragraph'
|
88
|
+
end
|
89
|
+
|
90
|
+
generated_html = app_class.api_documentation.to_html
|
91
|
+
expect(generated_html).to include('<h2>GET /pancakes</h2>')
|
92
|
+
expect(generated_html).to include('<h1>This describes even more important stuff')
|
93
|
+
expect(generated_html).to include('<h1>This describes important stuff')
|
94
|
+
expect(generated_html).to include('<p>This is a paragraph')
|
95
|
+
end
|
96
|
+
|
97
|
+
context 'with a file containing Markdown that has to be spliced into the docs' do
|
98
|
+
before(:each) { File.open('./TEST.md', 'w') {|f| f << "# This is an important header"} }
|
99
|
+
after(:each) { File.unlink('./TEST.md') }
|
100
|
+
it 'splices the contents of the file using markdown_file' do
|
101
|
+
app_class = Class.new(Sinatra::Base) do
|
102
|
+
extend Apiculture
|
103
|
+
markdown_file './TEST.md'
|
104
|
+
api_method :get, '/pancakes' do
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
generated_html = app_class.api_documentation.to_html
|
109
|
+
expect(generated_html).to include('<h2>GET /pancakes</h2>')
|
110
|
+
expect(generated_html).to include('<h1>This is an important header')
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,80 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
require_relative '../../lib/apiculture/method_documentation'
|
3
|
+
|
4
|
+
describe Apiculture::MethodDocumentation do
|
5
|
+
it 'generates HTML from an ActionDefinition with path, verb and both route and request params' do
|
6
|
+
definition = Apiculture::ActionDefinition.new
|
7
|
+
|
8
|
+
definition.description = "This action bakes pancakes"
|
9
|
+
definition.parameters << Apiculture::Parameter.new(:name, 'Pancake name', true, String, :to_s)
|
10
|
+
definition.parameters << Apiculture::Parameter.new(:thickness, 'Pancake thickness', false, Float, :to_f)
|
11
|
+
definition.parameters << Apiculture::Parameter.new(:diameter, 'Pancake diameter', false, Integer, :to_i)
|
12
|
+
|
13
|
+
definition.route_parameters << Apiculture::RouteParameter.new(:pan_id, 'ID of the pancake frying pan')
|
14
|
+
definition.http_verb = 'get'
|
15
|
+
definition.path = '/pancake/:pan_id/bake'
|
16
|
+
|
17
|
+
documenter = described_class.new(definition)
|
18
|
+
|
19
|
+
generated_html = documenter.to_html_fragment
|
20
|
+
generated_markdown = documenter.to_markdown
|
21
|
+
|
22
|
+
expect(generated_html).not_to include('<body>')
|
23
|
+
|
24
|
+
expect(generated_html).to include('<h2>GET /pancake/:pan_id/bake</h2>')
|
25
|
+
expect(generated_html).to include('<p>This action bakes pancakes</p>')
|
26
|
+
expect(generated_html).to include('<h3>URL parameters</h3>')
|
27
|
+
expect(generated_html).to include('ID of the pancake frying pan')
|
28
|
+
expect(generated_html).to include('<h3>Request parameters</h3>')
|
29
|
+
expect(generated_html).to include('<td>Pancake name</td>')
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'generates HTML from an ActionDefinition without route params' do
|
33
|
+
definition = Apiculture::ActionDefinition.new
|
34
|
+
|
35
|
+
definition.description = "This action bakes pancakes"
|
36
|
+
definition.parameters << Apiculture::Parameter.new(:name, 'Pancake name', true, String, :to_s)
|
37
|
+
definition.parameters << Apiculture::Parameter.new(:thickness, 'Pancake thickness', false, Float, :to_f)
|
38
|
+
definition.parameters << Apiculture::Parameter.new(:diameter, 'Pancake diameter', false, Integer, :to_i)
|
39
|
+
|
40
|
+
definition.http_verb = 'get'
|
41
|
+
definition.path = '/pancake'
|
42
|
+
|
43
|
+
documenter = described_class.new(definition)
|
44
|
+
generated_html = documenter.to_html_fragment
|
45
|
+
|
46
|
+
expect(generated_html).not_to include('<h3>URL parameters</h3>')
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'generates HTML from an ActionDefinition without request params' do
|
50
|
+
definition = Apiculture::ActionDefinition.new
|
51
|
+
|
52
|
+
definition.description = "This action bakes pancakes"
|
53
|
+
|
54
|
+
definition.route_parameters << Apiculture::RouteParameter.new(:pan_id, 'ID of the pancake frying pan')
|
55
|
+
definition.http_verb = 'get'
|
56
|
+
definition.path = '/pancake/:pan_id/bake'
|
57
|
+
|
58
|
+
documenter = described_class.new(definition)
|
59
|
+
|
60
|
+
generated_html = documenter.to_html_fragment
|
61
|
+
generated_markdown = documenter.to_markdown
|
62
|
+
|
63
|
+
expect(generated_html).not_to include('<h3>Request parameters</h3>')
|
64
|
+
end
|
65
|
+
|
66
|
+
it 'generates Markdown from an ActionDefinition with a mountpoint' do
|
67
|
+
definition = Apiculture::ActionDefinition.new
|
68
|
+
|
69
|
+
definition.description = "This action bakes pancakes"
|
70
|
+
|
71
|
+
definition.route_parameters << Apiculture::RouteParameter.new(:pan_id, 'ID of the pancake frying pan')
|
72
|
+
definition.http_verb = 'get'
|
73
|
+
definition.path = '/pancake/:pan_id/bake'
|
74
|
+
|
75
|
+
documenter = described_class.new(definition, '/api/v1')
|
76
|
+
|
77
|
+
generated_markdown = documenter.to_markdown
|
78
|
+
expect(generated_markdown).to include('## GET /api/v1/pancake/:pan_id')
|
79
|
+
end
|
80
|
+
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
|
2
|
+
|
3
|
+
describe "Apiculture" do
|
4
|
+
include Rack::Test::Methods
|
5
|
+
|
6
|
+
before(:each) { @app_class = nil }
|
7
|
+
def app
|
8
|
+
@app_class or raise "No @app_class defined in the example"
|
9
|
+
end
|
10
|
+
|
11
|
+
context 'as API definition DSL' do
|
12
|
+
it 'allows all the standard Siantra DSL to go through without modifications' do
|
13
|
+
@app_class = Class.new(Sinatra::Base) do
|
14
|
+
settings.show_exceptions = false
|
15
|
+
settings.raise_errors = true
|
16
|
+
extend Apiculture
|
17
|
+
|
18
|
+
post '/things/*' do
|
19
|
+
return params.inspect
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
post '/things/a/b/c/d', {'foo' => 'bar'}
|
24
|
+
expect(last_response.body).to eq("{\"foo\"=>\"bar\", \"splat\"=>[\"a/b/c/d\"], \"captures\"=>[\"a/b/c/d\"]}")
|
25
|
+
end
|
26
|
+
|
27
|
+
it 'flags :captures as a reserved Sinatra parameter when used as a URL param' do
|
28
|
+
expect {
|
29
|
+
Class.new(Sinatra::Base) do
|
30
|
+
extend Apiculture
|
31
|
+
route_param :captures, "Something it captures"
|
32
|
+
api_method(:get, '/thing/:captures') { raise "Should never be called" }
|
33
|
+
end
|
34
|
+
}.to raise_error(/\:captures is a reserved magic parameter name/)
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'flags :captures as a reserved Sinatra parameter when used as a request param' do
|
38
|
+
expect {
|
39
|
+
Class.new(Sinatra::Base) do
|
40
|
+
extend Apiculture
|
41
|
+
param :captures, "Something it captures", String
|
42
|
+
api_method(:get, '/thing') { raise "Should never be called" }
|
43
|
+
end
|
44
|
+
}.to raise_error(/\:captures is a reserved magic parameter name/)
|
45
|
+
end
|
46
|
+
|
47
|
+
it 'flags :splat as a reserved Sinatra parameter when used as a URL param' do
|
48
|
+
expect {
|
49
|
+
Class.new(Sinatra::Base) do
|
50
|
+
extend Apiculture
|
51
|
+
route_param :splat, "Something it splats"
|
52
|
+
api_method(:get, '/thing/:splat') { raise "Should never be called" }
|
53
|
+
end
|
54
|
+
}.to raise_error(/\:splat is a reserved magic parameter name/)
|
55
|
+
end
|
56
|
+
|
57
|
+
it 'flags :splat as a reserved Sinatra parameter when used as a request param' do
|
58
|
+
expect {
|
59
|
+
Class.new(Sinatra::Base) do
|
60
|
+
extend Apiculture
|
61
|
+
param :splat, "Something it splats", String
|
62
|
+
api_method(:get, '/thing') { raise "Should never be called" }
|
63
|
+
end
|
64
|
+
}.to raise_error(/\:splat is a reserved magic parameter name/)
|
65
|
+
end
|
66
|
+
|
67
|
+
it 'flags URL and request params of the same name' do
|
68
|
+
expect {
|
69
|
+
Class.new(Sinatra::Base) do
|
70
|
+
extend Apiculture
|
71
|
+
route_param :id, 'Id of the thing'
|
72
|
+
param :id, "Something it identifies (conflict)", String
|
73
|
+
api_method(:get, '/thing/:id') { raise "Should never be called" }
|
74
|
+
end
|
75
|
+
}.to raise_error(/\:id mentioned twice/)
|
76
|
+
end
|
77
|
+
|
78
|
+
it "defines a basic API that can be called" do
|
79
|
+
$created_thing = nil
|
80
|
+
@app_class = Class.new(Sinatra::Base) do
|
81
|
+
settings.show_exceptions = false
|
82
|
+
settings.raise_errors = true
|
83
|
+
|
84
|
+
extend Apiculture
|
85
|
+
|
86
|
+
desc "Create a Thing with a name"
|
87
|
+
route_param :id, "The ID of the thing"
|
88
|
+
required_param :name, "Name of the thing", String
|
89
|
+
api_method :post, '/thing/:id' do | thing_id |
|
90
|
+
$created_thing = {id: thing_id, name: params[:name]}
|
91
|
+
'Wild success'
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
post '/thing/123', {name: 'Monsieur Thing'}
|
96
|
+
expect(last_response.body).to eq('Wild success')
|
97
|
+
expect($created_thing).to eq({id: '123', name: 'Monsieur Thing'})
|
98
|
+
end
|
99
|
+
|
100
|
+
it "serves the API documentation at a given URL using serve_api_documentation_at" do
|
101
|
+
$created_thing = nil
|
102
|
+
@app_class = Class.new(Sinatra::Base) do
|
103
|
+
settings.show_exceptions = false
|
104
|
+
settings.raise_errors = true
|
105
|
+
|
106
|
+
extend Apiculture
|
107
|
+
|
108
|
+
desc "Create a Thing with a name"
|
109
|
+
required_param :name, "Name of the thing", String
|
110
|
+
api_method( :post, '/thing/:id') {}
|
111
|
+
serve_api_documentation_at('/documentation')
|
112
|
+
end
|
113
|
+
|
114
|
+
get '/documentation'
|
115
|
+
expect(last_response['Content-Type']).to include('text/html')
|
116
|
+
expect(last_response.body).to include('Create a Thing')
|
117
|
+
end
|
118
|
+
|
119
|
+
it 'raises when a required param is not provided' do
|
120
|
+
@app_class = Class.new(Sinatra::Base) do
|
121
|
+
settings.show_exceptions = false
|
122
|
+
settings.raise_errors = true
|
123
|
+
extend Apiculture
|
124
|
+
|
125
|
+
required_param :name, "Name of the thing", String
|
126
|
+
api_method :post, '/thing' do
|
127
|
+
raise "Should never be called"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
expect {
|
132
|
+
post '/thing', {}
|
133
|
+
}.to raise_error('Missing parameter :name')
|
134
|
+
end
|
135
|
+
|
136
|
+
it 'verifies the parameter type' do
|
137
|
+
@app_class = Class.new(Sinatra::Base) do
|
138
|
+
settings.show_exceptions = false
|
139
|
+
settings.raise_errors = true
|
140
|
+
extend Apiculture
|
141
|
+
|
142
|
+
required_param :number, "Number of the thing", Integer
|
143
|
+
api_method :post, '/thing' do
|
144
|
+
raise "Should never be called"
|
145
|
+
end
|
146
|
+
end
|
147
|
+
|
148
|
+
expect {
|
149
|
+
post '/thing', {number: '123'}
|
150
|
+
}.to raise_error('Received String, expected Integer for :number')
|
151
|
+
end
|
152
|
+
|
153
|
+
it 'suppresses parameters that are not defined in the action definition' do
|
154
|
+
@app_class = Class.new(Sinatra::Base) do
|
155
|
+
settings.show_exceptions = false
|
156
|
+
settings.raise_errors = true
|
157
|
+
extend Apiculture
|
158
|
+
|
159
|
+
api_method :post, '/thing' do
|
160
|
+
raise ":evil_ssh_injection should have wiped from params{}" if params[:evil_ssh_injection]
|
161
|
+
'All is well'
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
post '/thing', {evil_ssh_injection: 'I am Homakov!'}
|
166
|
+
expect(last_response).to be_ok
|
167
|
+
end
|
168
|
+
|
169
|
+
it 'raises when describing a route parameter that is not included in the path' do
|
170
|
+
expect {
|
171
|
+
Class.new(Sinatra::Base) do
|
172
|
+
extend Apiculture
|
173
|
+
route_param :thing_id, "The ID of the thing"
|
174
|
+
api_method(:get, '/thing/:id') { raise "Should never be called" }
|
175
|
+
end
|
176
|
+
}.to raise_error('Parameter :thing_id not present in path "/thing/:id"')
|
177
|
+
end
|
178
|
+
|
179
|
+
it 'applies a symbol typecast by calling a method on the parameter value' do
|
180
|
+
@app_class = Class.new(Sinatra::Base) do
|
181
|
+
settings.show_exceptions = false
|
182
|
+
settings.raise_errors = true
|
183
|
+
extend Apiculture
|
184
|
+
|
185
|
+
required_param :number, "Number of the thing", Integer, :cast => :to_i
|
186
|
+
api_method :post, '/thing' do
|
187
|
+
raise "Not cast" unless params[:number] == 123
|
188
|
+
'Total success'
|
189
|
+
end
|
190
|
+
end
|
191
|
+
post '/thing', {number: '123'}
|
192
|
+
expect(last_response.body).to eq('Total success')
|
193
|
+
end
|
194
|
+
|
195
|
+
it 'applies a Proc typecast by calling the proc (for example - for ISO8601 time)' do
|
196
|
+
@app_class = Class.new(Sinatra::Base) do
|
197
|
+
settings.show_exceptions = false
|
198
|
+
settings.raise_errors = true
|
199
|
+
extend Apiculture
|
200
|
+
|
201
|
+
required_param :when, "When it happened", Time, cast: ->(v){ Time.parse(v) }
|
202
|
+
api_method :post, '/occurrence' do
|
203
|
+
raise "Not cast" unless params[:when].year == 2015
|
204
|
+
raise "Not cast" unless params[:when].month == 7
|
205
|
+
'Total success'
|
206
|
+
end
|
207
|
+
end
|
208
|
+
post '/occurrence', {when: '2015-07-05T22:16:18Z'}
|
209
|
+
expect(last_response.body).to eq('Total success')
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
context 'Sinatra instance method extensions' do
|
214
|
+
it 'adds support for json_response' do
|
215
|
+
@app_class = Class.new(Sinatra::Base) do
|
216
|
+
extend Apiculture
|
217
|
+
settings.show_exceptions = false
|
218
|
+
settings.raise_errors = true
|
219
|
+
api_method :get, '/some-json' do
|
220
|
+
json_response({foo: 'bar'})
|
221
|
+
end
|
222
|
+
end
|
223
|
+
|
224
|
+
get '/some-json'
|
225
|
+
expect(last_response).to be_ok
|
226
|
+
expect(last_response['Content-Type']).to include('application/json')
|
227
|
+
parsed_body = JSON.load(last_response.body)
|
228
|
+
expect(parsed_body['foo']).to eq('bar')
|
229
|
+
end
|
230
|
+
|
231
|
+
it 'adds support for json_halt' do
|
232
|
+
@app_class = Class.new(Sinatra::Base) do
|
233
|
+
extend Apiculture
|
234
|
+
settings.show_exceptions = false
|
235
|
+
settings.raise_errors = true
|
236
|
+
api_method :get, '/simple-halt' do
|
237
|
+
json_halt "Nein."
|
238
|
+
raise "This should never be called"
|
239
|
+
end
|
240
|
+
api_method :get, '/halt-with-custom-status' do
|
241
|
+
json_halt 'Nein.', status: 503
|
242
|
+
raise "This should never be called"
|
243
|
+
end
|
244
|
+
api_method :get, '/halt-with-error-payload' do
|
245
|
+
json_halt 'Nein.', teapot: true
|
246
|
+
raise "This should never be called"
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
get '/simple-halt'
|
251
|
+
expect(last_response.status).to eq(400)
|
252
|
+
expect(last_response['Content-Type']).to include('application/json')
|
253
|
+
parsed_body = JSON.load(last_response.body)
|
254
|
+
expect(parsed_body).to eq({"error"=>"Nein."})
|
255
|
+
|
256
|
+
get '/halt-with-error-payload'
|
257
|
+
expect(last_response.status).to eq(400)
|
258
|
+
expect(last_response['Content-Type']).to include('application/json')
|
259
|
+
parsed_body = JSON.load(last_response.body)
|
260
|
+
expect(parsed_body).to eq({"error"=>"Nein.", "teapot"=>true})
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|