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.
@@ -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