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,46 @@
|
|
1
|
+
# An Action is like a Sinatra route method wrapped in a class.
|
2
|
+
# It gets instantiated with a number of instance variables (via keyword arguments)
|
3
|
+
# and the Sinatra application calling the action.
|
4
|
+
#
|
5
|
+
# All the methods available within Sinatra are also available within the Action,
|
6
|
+
# via method delegation (this primarily concerns methods like +request+, +env+, +params+
|
7
|
+
# and so forth).
|
8
|
+
#
|
9
|
+
# The main work method is +perform+ which should return a data structure that can be converted
|
10
|
+
# into JSON by the caller.
|
11
|
+
class Apiculture::Action
|
12
|
+
# Initialize a new BasicAction, with the given Sintra application and a hash
|
13
|
+
# of keyword arguments that will be converted into instance variables.
|
14
|
+
def initialize(sinatra_app, **ivars)
|
15
|
+
ivars.each_pair {|k,v| instance_variable_set("@#{k}", v) }
|
16
|
+
@_sinatra_app = sinatra_app
|
17
|
+
end
|
18
|
+
|
19
|
+
# Halt with a JSON error message (delegates to Sinatra's halt() under the hood)
|
20
|
+
def bail(with_error_message, status: 400, **attrs_for_json_response)
|
21
|
+
@_sinatra_app.json_halt(with_error_message, status: status, **attrs_for_json_response)
|
22
|
+
end
|
23
|
+
|
24
|
+
# Respond to all the methods the contained Sinatra app supports
|
25
|
+
def respond_to_missing?(*a)
|
26
|
+
super || @_sinatra_app.respond_to?(*a)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Respond to all the methods the contained Sinatra app supports
|
30
|
+
def method_missing(m, *a, &b)
|
31
|
+
if @_sinatra_app.respond_to?(m)
|
32
|
+
@_sinatra_app.public_send(m, *a, &b)
|
33
|
+
else
|
34
|
+
super
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
# Performs the action and returns it's result.
|
39
|
+
#
|
40
|
+
# If the action result is an Array or a Hash, it will be converted into JSON
|
41
|
+
# and output.
|
42
|
+
#
|
43
|
+
# If something else is returned an error will be raised.
|
44
|
+
def perform
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# Describes a single API action (route). Is used internally by Apiculture.
|
2
|
+
class Apiculture::ActionDefinition
|
3
|
+
attr_accessor :description
|
4
|
+
attr_accessor :http_verb
|
5
|
+
attr_accessor :path
|
6
|
+
|
7
|
+
attr_reader :parameters
|
8
|
+
attr_reader :route_parameters
|
9
|
+
attr_reader :responses
|
10
|
+
|
11
|
+
def all_parameter_names_as_strings
|
12
|
+
@parameters.map(&:name_as_string) + @route_parameters.map(&:name_as_string)
|
13
|
+
end
|
14
|
+
|
15
|
+
def defines_responses?
|
16
|
+
@responses.any?
|
17
|
+
end
|
18
|
+
|
19
|
+
def defines_request_params?
|
20
|
+
@parameters.any?
|
21
|
+
end
|
22
|
+
|
23
|
+
def defines_route_params?
|
24
|
+
@route_parameters.any?
|
25
|
+
end
|
26
|
+
|
27
|
+
def initialize
|
28
|
+
@parameters, @route_parameters, @responses = [], [], []
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
require_relative 'method_documentation'
|
2
|
+
require 'github/markup'
|
3
|
+
|
4
|
+
class Apiculture::AppDocumentation
|
5
|
+
class TaggedMarkdown < Struct.new(:string, :section_class)
|
6
|
+
def to_markdown
|
7
|
+
string.to_markdown.to_s rescue string.to_s
|
8
|
+
end
|
9
|
+
|
10
|
+
def to_html
|
11
|
+
'<section class="%s">%s</section>' % [Rack::Utils.escape_html(section_class), render_markdown(to_markdown)]
|
12
|
+
end
|
13
|
+
|
14
|
+
def render_markdown(s)
|
15
|
+
GitHub::Markup.render('section.markdown', s.to_s)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
def initialize(app, mountpoint, action_definitions_and_markdown_segments)
|
20
|
+
@app_title = app.to_s
|
21
|
+
@mountpoint = mountpoint
|
22
|
+
@chunks = action_definitions_and_markdown_segments
|
23
|
+
end
|
24
|
+
|
25
|
+
# Generates a Markdown string that contains the entire API documentation
|
26
|
+
def to_markdown
|
27
|
+
(['## %s' % @app_title] + to_markdown_slices).join("\n\n")
|
28
|
+
end
|
29
|
+
|
30
|
+
# Generates an HTML fragment string that can be included into another HTML document
|
31
|
+
def to_html_fragment
|
32
|
+
to_markdown_slices.map do |tagged_markdown|
|
33
|
+
tagged_markdown.to_html
|
34
|
+
end.join("\n\n")
|
35
|
+
end
|
36
|
+
|
37
|
+
def to_markdown_slices
|
38
|
+
markdown_slices = @chunks.map do | action_def_or_doc |
|
39
|
+
if action_def_or_doc.respond_to?(:http_verb) # ActionDefinition
|
40
|
+
s = Apiculture::MethodDocumentation.new(action_def_or_doc, @mountpoint).to_markdown
|
41
|
+
TaggedMarkdown.new(s, 'apiculture-method')
|
42
|
+
elsif action_def_or_doc.respond_to?(:to_markdown)
|
43
|
+
TaggedMarkdown.new(action_def_or_doc, 'apiculture-verbatim')
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
# Generates a complete HTML document string that can be saved into a file
|
49
|
+
def to_html
|
50
|
+
require 'mustache'
|
51
|
+
template = File.read(__dir__ + '/app_documentation_tpl.mustache')
|
52
|
+
Mustache.render(template, :html_fragment => to_html_fragment)
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
<!DOCTYPE html>
|
2
|
+
<html>
|
3
|
+
<head>
|
4
|
+
<meta name="charset" contents="utf-8">
|
5
|
+
<title>API documentation</title>
|
6
|
+
<style type="text/css">
|
7
|
+
html {
|
8
|
+
background: #f0f0f0;
|
9
|
+
height: 100%;
|
10
|
+
}
|
11
|
+
|
12
|
+
body {
|
13
|
+
margin: 0 0 0 1.5rem;
|
14
|
+
padding: 1.5rem;
|
15
|
+
background: #fff;
|
16
|
+
min-height: 100%;
|
17
|
+
border-left: 1px solid #ddd;
|
18
|
+
font-family: Arial, Helvetica, sans-serif;
|
19
|
+
-webkit-font-smoothing: antialiased;
|
20
|
+
}
|
21
|
+
|
22
|
+
h1, h2, h3, h4, h5, h6 {
|
23
|
+
font-family: Arial, sans-serif;
|
24
|
+
font-weight: normal;
|
25
|
+
}
|
26
|
+
|
27
|
+
h1, h2, h3, h4, h5, h6, p, table, ul, ol {
|
28
|
+
margin: 0 0 1rem;
|
29
|
+
}
|
30
|
+
|
31
|
+
h2 {
|
32
|
+
margin-bottom: .6rem;
|
33
|
+
}
|
34
|
+
|
35
|
+
h2:first-word {
|
36
|
+
color: red;
|
37
|
+
}
|
38
|
+
|
39
|
+
h3 {
|
40
|
+
-webkit-box-sizing: border-box;
|
41
|
+
-moz-box-sizing: border-box;
|
42
|
+
box-sizing: border-box;
|
43
|
+
|
44
|
+
margin-bottom: 0;
|
45
|
+
font-weight: bold;
|
46
|
+
background: #3F92FF;
|
47
|
+
max-width: 60rem;
|
48
|
+
font-size: .95em;
|
49
|
+
padding: .5rem 1rem;
|
50
|
+
color: #fff;
|
51
|
+
}
|
52
|
+
|
53
|
+
h3 + table.apiculture-table {
|
54
|
+
background: #F9F9F9;
|
55
|
+
padding: 1rem;
|
56
|
+
border: 1px solid #ddd;
|
57
|
+
}
|
58
|
+
|
59
|
+
.apiculture-table {
|
60
|
+
-webkit-box-sizing: border-box;
|
61
|
+
-moz-box-sizing: border-box;
|
62
|
+
box-sizing: border-box;
|
63
|
+
|
64
|
+
border-spacing: 0;
|
65
|
+
max-width: 60rem;
|
66
|
+
margin: 0 0 2rem;
|
67
|
+
width: 100%;
|
68
|
+
}
|
69
|
+
|
70
|
+
table.apiculture-table + h3,
|
71
|
+
table.apiculture-table + table.apiculture-table {
|
72
|
+
margin-top: -1rem;
|
73
|
+
/* decreases table bottom margin for double table */
|
74
|
+
}
|
75
|
+
|
76
|
+
table.apiculture-table td,
|
77
|
+
table.apiculture-table th {
|
78
|
+
vertical-align: top;
|
79
|
+
text-align: left;
|
80
|
+
font-size: .9em;
|
81
|
+
}
|
82
|
+
|
83
|
+
table.apiculture-table th {
|
84
|
+
border-bottom: 1px solid #CBCBCB;
|
85
|
+
padding: .2rem .5rem .5rem;
|
86
|
+
}
|
87
|
+
|
88
|
+
table.apiculture-table td {
|
89
|
+
padding: .2rem .5rem;
|
90
|
+
}
|
91
|
+
|
92
|
+
tt, code {
|
93
|
+
font-family: 'Lucida Console', Monaco, monospace;
|
94
|
+
font-weight: normal;
|
95
|
+
font-size: .9em;
|
96
|
+
}
|
97
|
+
</style>
|
98
|
+
</head>
|
99
|
+
|
100
|
+
<body>
|
101
|
+
{{& html_fragment }}
|
102
|
+
</body>
|
103
|
+
</html>
|
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'builder'
|
2
|
+
# Generates Markdown/HTML documentation about a single API action.
|
3
|
+
#
|
4
|
+
# Formats route parameters and request/QS parameters as a neat HTML
|
5
|
+
# table, listing types, requirements and descriptions.
|
6
|
+
#
|
7
|
+
# Is used by AppDocumentation to compile a document on the entire app's API
|
8
|
+
# structure in one go.
|
9
|
+
class Apiculture::MethodDocumentation
|
10
|
+
def initialize(action_definition, mountpoint = '')
|
11
|
+
@definition = action_definition
|
12
|
+
@mountpoint = mountpoint
|
13
|
+
end
|
14
|
+
|
15
|
+
# Compose a Markdown definition of the action
|
16
|
+
def to_markdown
|
17
|
+
m = MDBuf.new
|
18
|
+
m << "## #{@definition.http_verb.upcase} #{@mountpoint}#{@definition.path}"
|
19
|
+
m << @definition.description
|
20
|
+
m << route_parameters_table
|
21
|
+
m << request_parameters_table
|
22
|
+
m << possible_responses_table
|
23
|
+
|
24
|
+
m.to_s
|
25
|
+
end
|
26
|
+
|
27
|
+
# Compose an HTML string by converting the result of +to_markdown+
|
28
|
+
def to_html_fragment
|
29
|
+
require 'rdiscount'
|
30
|
+
RDiscount.new(to_markdown).to_html
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
class StringBuf #:nodoc:
|
36
|
+
def initialize; @blocks = []; end
|
37
|
+
def <<(block); @blocks << block.to_s; self; end
|
38
|
+
def to_s; @blocks.join; end
|
39
|
+
end
|
40
|
+
|
41
|
+
class MDBuf < StringBuf #:nodoc:
|
42
|
+
def to_s; @blocks.join("\n\n"); end
|
43
|
+
end
|
44
|
+
|
45
|
+
def route_parameters_table
|
46
|
+
return '' unless @definition.defines_route_params?
|
47
|
+
|
48
|
+
m = MDBuf.new
|
49
|
+
b = StringBuf.new
|
50
|
+
m << '### URL parameters'
|
51
|
+
|
52
|
+
html = Builder::XmlMarkup.new(:target => b)
|
53
|
+
html.table(class: 'apiculture-table') do
|
54
|
+
html.tr do
|
55
|
+
html.th 'Name'
|
56
|
+
html.th 'Description'
|
57
|
+
end
|
58
|
+
|
59
|
+
@definition.route_parameters.each do | param |
|
60
|
+
html.tr do
|
61
|
+
html.td { html.tt(':%s' % param.name) }
|
62
|
+
html.td(param.description)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
m << b.to_s
|
67
|
+
end
|
68
|
+
|
69
|
+
def body_example(for_response_definition)
|
70
|
+
if for_response_definition.no_body?
|
71
|
+
'(empty)'
|
72
|
+
else
|
73
|
+
JSON.pretty_generate(for_response_definition.jsonable_object_example)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def possible_responses_table
|
78
|
+
return '' unless @definition.defines_responses?
|
79
|
+
|
80
|
+
m = MDBuf.new
|
81
|
+
b = StringBuf.new
|
82
|
+
m << '### Possible responses'
|
83
|
+
|
84
|
+
html = Builder::XmlMarkup.new(:target => b)
|
85
|
+
html.table(class: 'apiculture-table') do
|
86
|
+
html.tr do
|
87
|
+
html.th('HTTP status code')
|
88
|
+
html.th('What happened')
|
89
|
+
html.th('Example response body')
|
90
|
+
end
|
91
|
+
|
92
|
+
@definition.responses.each do | resp |
|
93
|
+
html.tr do
|
94
|
+
html.td { html.b(resp.http_status_code) }
|
95
|
+
html.td resp.description
|
96
|
+
html.td { html.pre { html.code(body_example(resp)) }}
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
m << b.to_s
|
102
|
+
end
|
103
|
+
|
104
|
+
def request_parameters_table
|
105
|
+
return '' unless @definition.defines_request_params?
|
106
|
+
|
107
|
+
m = MDBuf.new
|
108
|
+
m << '### Request parameters'
|
109
|
+
b = StringBuf.new
|
110
|
+
html = Builder::XmlMarkup.new(:target => b)
|
111
|
+
html.table(class: 'apiculture-table') do
|
112
|
+
html.tr do
|
113
|
+
html.th 'Name'
|
114
|
+
html.th 'Required'
|
115
|
+
html.th 'Type after cast'
|
116
|
+
html.th 'Description'
|
117
|
+
end
|
118
|
+
|
119
|
+
@definition.parameters.each do | param |
|
120
|
+
html.tr do
|
121
|
+
html.td { html.tt(param.name.to_s) }
|
122
|
+
html.td(param.required ? 'Yes' : 'No')
|
123
|
+
html.td(param.ruby_type.to_s)
|
124
|
+
html.td(param.description.to_s)
|
125
|
+
end
|
126
|
+
end
|
127
|
+
end
|
128
|
+
m << b
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
# Some sugary methods for use within Sinatra, when responding to a request/rendering JSON
|
4
|
+
module Apiculture::SinatraInstanceMethods
|
5
|
+
NEWLINE = "\n"
|
6
|
+
|
7
|
+
# Convert the given structure to JSON, set the content-type and
|
8
|
+
# return the JSON string
|
9
|
+
def json_response(structure)
|
10
|
+
content_type :json
|
11
|
+
JSON.pretty_generate(structure)
|
12
|
+
end
|
13
|
+
|
14
|
+
# Bail out from an action by sending a halt() via Sinatra. Is most useful for
|
15
|
+
# handling access denied, invalid resource and other types of situations
|
16
|
+
# where you don't want the request to continue, but still would like to
|
17
|
+
# provide a decent error message to the client that it can parse
|
18
|
+
# with it's own JSON means.
|
19
|
+
def json_halt(with_error_message, status: 400, **attrs_for_json_response)
|
20
|
+
# Pretty-print + newline to be terminal-friendly
|
21
|
+
err_str = JSON.pretty_generate({error: with_error_message}.merge(attrs_for_json_response)) + NEWLINE
|
22
|
+
halt status, {'Content-Type' => 'application/json'}, [err_str]
|
23
|
+
end
|
24
|
+
|
25
|
+
# Handles the given action via the given class, passing it the instance variables
|
26
|
+
# given in the keyword arguments
|
27
|
+
def action_result(action_class, **action_ivars)
|
28
|
+
call_result = action_class.new(self, **action_ivars).perform
|
29
|
+
|
30
|
+
unless call_result.is_a?(Array) || call_result.is_a?(Hash)
|
31
|
+
raise "Action result should be an Array or a Hash, but was a #{call_result.class}"
|
32
|
+
end
|
33
|
+
|
34
|
+
json_response call_result
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require_relative '../spec_helper'
|
2
|
+
|
3
|
+
describe Apiculture::Action do
|
4
|
+
context '.new' do
|
5
|
+
it 'exposes the methods of the object given as a first argument to initialize' do
|
6
|
+
action_class = Class.new(described_class)
|
7
|
+
fake_sinatra = double('Sinatra::Base', something: 'value')
|
8
|
+
action = action_class.new(fake_sinatra)
|
9
|
+
expect(action).to respond_to(:something)
|
10
|
+
expect(action.something).to eq('value')
|
11
|
+
end
|
12
|
+
|
13
|
+
it 'converts keyword arguments to instance variables' do
|
14
|
+
action_class = Class.new(described_class)
|
15
|
+
action = action_class.new(nil, foo: 'a string')
|
16
|
+
expect(action.instance_variable_get('@foo')).to eq('a string')
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'responds to perform()' do
|
21
|
+
expect(described_class.new(nil)).to respond_to(:perform)
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'can use bail() to throw a Sinatra halt' do
|
25
|
+
fake_sinatra = double('Sinatra::Base')
|
26
|
+
expect(fake_sinatra).to receive(:json_halt).with('Failure', status: 400)
|
27
|
+
action_class = Class.new(described_class)
|
28
|
+
action_class.new(fake_sinatra).bail "Failure"
|
29
|
+
end
|
30
|
+
|
31
|
+
it 'can use bail() to throw a Sinatra halt with a custom status' do
|
32
|
+
fake_sinatra = double('Sinatra::Base')
|
33
|
+
expect(fake_sinatra).to receive(:json_halt).with("Failure", status: 417)
|
34
|
+
|
35
|
+
action_class = Class.new(described_class)
|
36
|
+
action_class.new(fake_sinatra).bail "Failure", status: 417
|
37
|
+
end
|
38
|
+
|
39
|
+
it 'can use bail() to throw a Sinatra halt with extra JSON attributes' do
|
40
|
+
fake_sinatra = double('Sinatra::Base')
|
41
|
+
expect(fake_sinatra).to receive(:json_halt).with("Failure", status: 417, message: "Totale")
|
42
|
+
action_class = Class.new(described_class)
|
43
|
+
action_class.new(fake_sinatra).bail "Failure", status: 417, message: 'Totale'
|
44
|
+
end
|
45
|
+
end
|