apiculture 0.0.12

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,6 @@
1
+ # Just a tiny String container for literal documentation chunks
2
+ class Apiculture::MarkdownSegment < Struct.new(:string)
3
+ def to_markdown
4
+ string.to_s
5
+ end
6
+ end
@@ -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,6 @@
1
+ class Apiculture::TimestampPromise
2
+ def self.to_markdown
3
+ ts = Time.now.utc.strftime "%Y-%m-%d %H:%M"
4
+ "Documentation built on #{ts}"
5
+ end
6
+ end
@@ -0,0 +1,3 @@
1
+ module Apiculture
2
+ VERSION = '0.0.12'
3
+ 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