apiculture 0.1.6 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be857ef48fe8ec836d116ed89ed39c2cf55d2d60e4a25ae81eab0ccdf2b05f36
4
- data.tar.gz: 737058ecf04cb0af193c24438f7c3a96c16a2551079aa13f4bcfbed2afa14fe8
3
+ metadata.gz: 21e9fada9f8ba88c058f19789d597059bb30b055fe92c10e27953c5df6296758
4
+ data.tar.gz: 7b3c02a5587b6976a2a1a7b11656834824e32887b9dde3922542687fcbe63c71
5
5
  SHA512:
6
- metadata.gz: 39564495733b83ca4ce0e1963b470d9836e5ce549827f93689bebfe4f5f8e57fb368497a407142f1c6c78143ad6c56a6a5a4f74ba29bdf199704bce58d720885
7
- data.tar.gz: 6c1e15fe7e3c009349d17cf59d5686b81ccc6e6c94724a89b343b404afd3c013ee8505bebe75c3228774215f7065c1faf11872589de6e6b78abafe8d462f78f7
6
+ metadata.gz: b0818c0fd6c04efa47f0cabcd0927c13516bd293d7486fea8750b7325d260208b3134432ebf43fbc9d7f0321a8ff7b8e5ea07e6f679e59b1df62e0f5f105f1b7
7
+ data.tar.gz: 853ca12aefe6005ae08432d29f902f6a1584252db87663c08c96a00f47d7b771108e2485f39e7400615e8f54243f68f6abf2e8406a963def5e3821c9ee62a6fc
data/lib/apiculture.rb CHANGED
@@ -9,6 +9,7 @@ module Apiculture
9
9
  require_relative 'apiculture/markdown_segment'
10
10
  require_relative 'apiculture/timestamp_promise'
11
11
  require_relative 'apiculture/app_documentation'
12
+ require_relative 'apiculture/openapi_documentation'
12
13
 
13
14
  def self.extended(in_class)
14
15
  in_class.send(:include, SinatraInstanceMethods)
@@ -205,7 +206,7 @@ module Apiculture
205
206
  def api_documentation
206
207
  AppDocumentation.new(self, @apiculture_mounted_at.to_s, @apiculture_actions_and_docs || [])
207
208
  end
208
-
209
+
209
210
  # Define an API method. Under the hood will call the related methods in Sinatra
210
211
  # to define the route.
211
212
  def api_method(http_verb, path, options={}, &blk)
@@ -25,6 +25,10 @@ class Apiculture::AppDocumentation
25
25
  # Generates a Markdown string that contains the entire API documentation
26
26
  def to_markdown
27
27
  (['## %s' % @app_title] + to_markdown_slices).join("\n\n")
28
+ end
29
+
30
+ def to_openapi
31
+ OpenApiDocumentation::Base.new(@app_title, @mountpoint, @chunks)
28
32
  end
29
33
 
30
34
  # Generates an HTML fragment string that can be included into another HTML document
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+ require 'yaml'
3
+ require 'base64'
4
+ module OpenApiDocumentation
5
+ class Base
6
+ def initialize(app, prefix, chunks)
7
+ @app, @prefix = app, prefix
8
+ @paths = chunks.select { |chunk| chunk.respond_to?(:http_verb) }
9
+ @data = {
10
+ openapi: '3.0.0',
11
+ info: {
12
+ title: @app.to_s,
13
+ version: '0.0.1',
14
+ description: @app.to_s + " " + chunks.select { |chunk| chunk.respond_to?(:to_markdown) }.map(&:to_markdown).join("\n")
15
+ },
16
+ tags: []
17
+ }
18
+ @data[:paths] = build_paths
19
+ end
20
+
21
+ def to_yaml
22
+ JSON.load(@data.to_json).to_yaml # trickery to get string based yaml
23
+ end
24
+
25
+ def paths
26
+ @data[:paths]
27
+ end
28
+
29
+ def spec
30
+ @data
31
+ end
32
+
33
+ private
34
+
35
+ def build_paths
36
+ paths = {}
37
+ @paths.each do |path|
38
+ path = Path.new(path, @prefix, @app)
39
+ paths = merge_paths(paths, path)
40
+ end
41
+ paths
42
+ end
43
+
44
+ # We don't have deep_merge here so this is the poor man's alternative
45
+ def merge_paths(paths, path)
46
+ if paths.key?(path.name)
47
+ paths[path.name].merge!(path.build[path.name])
48
+ else
49
+ paths.merge!(path.build)
50
+ end
51
+ paths
52
+ end
53
+
54
+ end
55
+
56
+ class Path
57
+ VERBS_WITHOUT_BODY = %w(get head delete options)
58
+
59
+ def initialize(path, prefix, app)
60
+ @path, @prefix, @app = path, prefix, app
61
+ end
62
+
63
+ def build
64
+ request_body = build_request_body unless VERBS_WITHOUT_BODY.include?(@path.http_verb)
65
+ {
66
+ name =>
67
+ {
68
+ @path.http_verb.to_sym => {
69
+ summary: @path.description,
70
+ description: @path.description,
71
+ tags: [ @app.to_s ],
72
+ parameters: build_parameters,
73
+ requestBody: request_body,
74
+ responses: build_responses,
75
+ operationId: operation_id
76
+ }.delete_if { |_k, v| v.nil? || v.empty? }
77
+ }
78
+ }
79
+ end
80
+
81
+ def name
82
+ full_path = @path.path.to_s
83
+ @path.route_parameters.each do |parameter|
84
+ # This is a bit confusing but naming is a little different between
85
+ # apiculture and openapi
86
+ full_path.gsub!(":#{parameter.name}", "\{#{parameter.name}\}")
87
+ end
88
+ Util.clean_path("#{@prefix}#{full_path}")
89
+ end
90
+
91
+ private
92
+
93
+ def operation_id
94
+ # base64 encoding to make sure these ids are safe to use in an url
95
+ Base64.urlsafe_encode64("#{@path.http_verb}#{@prefix}#{@path.path}")
96
+ end
97
+
98
+ def build_parameters
99
+ if VERBS_WITHOUT_BODY.include?(@path.http_verb)
100
+ build_route_parameters + build_query_parameters
101
+ else
102
+ build_route_parameters
103
+ end
104
+ end
105
+
106
+ def build_route_parameters
107
+ route_params = @path.route_parameters.map do |parameter|
108
+ {
109
+ name: parameter.name,
110
+ description: parameter.description,
111
+ required: true,
112
+ in: :path,
113
+ schema: {
114
+ type: Util.map_type(parameter.matchable),
115
+ example: Util.map_example(parameter.matchable)
116
+ }
117
+ }
118
+ end
119
+ route_params
120
+ end
121
+
122
+ def build_query_parameters
123
+ params = @path.parameters.map do |parameter|
124
+ {
125
+ name: parameter.name,
126
+ description: parameter.description,
127
+ required: true,
128
+ in: :query,
129
+ schema: {
130
+ type: Util.map_type(parameter.matchable),
131
+ example: parameter.matchable
132
+ }
133
+ }
134
+ end
135
+ params
136
+ end
137
+
138
+ def build_request_body
139
+ return nil if VERBS_WITHOUT_BODY.include?(@path.http_verb)
140
+
141
+ body_params = Hash[ @path.parameters.collect do |parameter|
142
+ [parameter.name, {
143
+ type: Util.map_type(parameter.matchable),
144
+ description: parameter.description
145
+ }]
146
+ end ]
147
+
148
+ return nil if body_params.count == 0
149
+
150
+ schema = {
151
+ type: :object,
152
+ properties: body_params
153
+ }
154
+
155
+ schema[:required] = @path.parameters.select(&:required).map(&:name) if @path.parameters.select(&:required).map(&:name).count > 0
156
+ {
157
+ content: {
158
+ "application/json": {
159
+ schema: schema
160
+ }
161
+ }
162
+ }
163
+ end
164
+
165
+ def build_responses
166
+ responses = Hash[@path.responses.collect do |response|
167
+ _response = {
168
+ description: response.description
169
+ }
170
+
171
+ unless response.jsonable_object_example.nil? || response.jsonable_object_example.empty?
172
+ _response[:content] = {
173
+ 'application/json': {
174
+ schema:
175
+ { type: 'object',
176
+ properties: Util.response_to_schema(response.jsonable_object_example) }
177
+ }
178
+ }
179
+ end
180
+
181
+ [response.http_status_code.to_s, _response]
182
+ end ]
183
+ responses
184
+ end
185
+ end
186
+
187
+ class Util
188
+ TYPES = {
189
+ String => 'string',
190
+ Integer => 'integer',
191
+ TrueClass => 'boolean'
192
+ }.freeze
193
+
194
+ EXAMPLES = {
195
+ String => 'string',
196
+ Integer => 1234,
197
+ TrueClass => true
198
+ }.freeze
199
+
200
+ def self.response_to_schema(response)
201
+ schema = {}
202
+ return nil if response.nil? || response.empty? || response.class == String
203
+ response.each do |key, value|
204
+ schema[key] = {
205
+ type: 'string',
206
+ example: value.to_s
207
+ }
208
+ end
209
+ schema
210
+ end
211
+
212
+ def self.map_type(type)
213
+ TYPES.fetch(type, 'string')
214
+ end
215
+
216
+ def self.map_example(type)
217
+ EXAMPLES.fetch(type, 'string')
218
+ end
219
+
220
+ def self.clean_path(path)
221
+ path.gsub(/\/\?\*\?$/, '')
222
+ end
223
+ end
224
+ end
@@ -1,3 +1,3 @@
1
1
  module Apiculture
2
- VERSION = '0.1.6'
2
+ VERSION = '0.1.7'
3
3
  end
@@ -0,0 +1,197 @@
1
+ require_relative '../spec_helper'
2
+
3
+ describe 'Apiculture.api_documentation' do
4
+ let!(:test_class) do
5
+ class PancakeApi < Apiculture::App
6
+ extend Apiculture
7
+
8
+ documentation_build_time!
9
+
10
+ desc 'Order a pancake'
11
+ required_param :diameter, 'Diameter of the pancake. The pancake will be **bold**', Integer
12
+ param :topping, 'Type of topping', String
13
+ pancake_response_info = <<~EOS
14
+ When the pancake has been baked successfully
15
+ The pancake will have the following properties:
16
+
17
+ * It is going to be round
18
+ * It is going to be delicious
19
+ EOS
20
+ responds_with 200, pancake_response_info, { id: 'abdef..c21' }
21
+ api_method :post, '/pancakes' do
22
+ end
23
+
24
+ desc 'Check the pancake status'
25
+ route_param :id, 'Pancake ID to check status on'
26
+ responds_with 200, 'When the pancake is found', { status: 'Baking' }
27
+ responds_with 404, 'When no such pancake exists', { status: 'No such pancake' }
28
+ api_method :get, '/pancake/:id' do
29
+ end
30
+
31
+ desc 'Throw away the pancake'
32
+ route_param :id, 'Pancake ID to delete'
33
+ api_method :delete, '/pancake/:id' do
34
+ end
35
+
36
+ desc 'Pancake ingredients are in the URL'
37
+ route_param :topping_id, 'Pancake topping ID', Integer, cast: :to_i
38
+ api_method :get, '/pancake/with/:topping_id' do |topping_id|
39
+ end
40
+ end
41
+ end
42
+
43
+ let(:documentation) { PancakeApi.api_documentation }
44
+ let(:open_api_map) { documentation.to_openapi.spec }
45
+
46
+ describe '.to_openapi' do
47
+ it 'will have openapi version' do
48
+ expect(open_api_map).to include(openapi: '3.0.0')
49
+ end
50
+
51
+ describe 'info' do
52
+ let(:info) { open_api_map.fetch(:info) }
53
+
54
+ it 'will not to be empty' do
55
+ expect(info).not_to be_empty
56
+ end
57
+
58
+ it 'will have title' do
59
+ expect(info).to include(title: 'PancakeApi')
60
+ end
61
+
62
+ it 'will have version' do
63
+ expect(info).to include(version: '0.0.1')
64
+ end
65
+
66
+ it 'will have description' do
67
+ expect(info).to include(:description)
68
+ expect(info[:description]).to include('PancakeApi Documentation built on')
69
+ end
70
+ end
71
+
72
+ describe 'paths' do
73
+ let(:paths) { open_api_map.fetch(:paths) }
74
+
75
+ it 'will have paths' do
76
+ expect(paths).not_to be_empty
77
+ end
78
+
79
+ it 'will have 4 paths' do
80
+ expect(paths.size).to eq(3)
81
+ end
82
+
83
+ context 'POST /pancakes' do
84
+ let(:post_pancakes) { paths.dig('/pancakes', :post) }
85
+
86
+ it 'will have route' do
87
+ expect(post_pancakes).not_to be_empty
88
+ end
89
+
90
+ describe 'request body content' do
91
+ let(:request_content) { post_pancakes.dig(:requestBody, :content) }
92
+ it 'will have correct JSON content type' do
93
+ expect(request_content).to have_key(:'application/json')
94
+ end
95
+
96
+ describe 'schema' do
97
+ let(:schema) { request_content.dig(:'application/json', :schema) }
98
+ it 'will have type' do
99
+ expect(schema).to include(type: :object)
100
+ end
101
+
102
+ it 'will have required parameters' do
103
+ expect(schema).to include(required: [:diameter])
104
+ end
105
+
106
+ describe 'properties' do
107
+ let(:properties) { schema.fetch(:properties) }
108
+ it 'will have all properties' do
109
+ expect(properties).to have_key :diameter
110
+ expect(properties).to have_key :topping
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ context 'GET /pancake/:id' do
118
+ let(:get_pancake_by_id_path) { paths.dig('/pancake/{id}', :get) }
119
+ it 'will have route' do
120
+ expect(get_pancake_by_id_path).not_to be_empty
121
+ end
122
+
123
+ it 'will have summary' do
124
+ expect(get_pancake_by_id_path).to include(summary: 'Check the pancake status')
125
+ end
126
+
127
+ it 'will have a description' do
128
+ expect(get_pancake_by_id_path).to include(description: 'Check the pancake status')
129
+ end
130
+
131
+ it 'will have operationId' do
132
+ expect(get_pancake_by_id_path).to have_key :operationId
133
+ end
134
+
135
+ describe 'parameters' do
136
+ let(:parameter) { get_pancake_by_id_path.fetch(:parameters).first }
137
+
138
+ it 'will contain parameter' do
139
+ expect(parameter).not_to be_empty
140
+ end
141
+
142
+ it 'will have required propertie' do
143
+ expect(parameter).to include(required: true)
144
+ end
145
+
146
+ it 'will indicate a path parameter' do
147
+ expect(parameter).to include(in: :path)
148
+ end
149
+
150
+ describe 'schema' do
151
+ let(:schema) { parameter.fetch(:schema) }
152
+ it 'will have object type' do
153
+ expect(schema).to include(type: 'string')
154
+ end
155
+
156
+ it 'will have an example' do
157
+ expect(schema).to include(example: 'string')
158
+ end
159
+ end
160
+ end
161
+
162
+ describe 'responses' do
163
+ let(:responses) { get_pancake_by_id_path.fetch(:responses) }
164
+
165
+ it 'will contain all responses by response code' do
166
+ expect(responses).to have_key('200')
167
+ expect(responses).to have_key('404')
168
+ end
169
+
170
+ describe 'response 200' do
171
+ let(:response_200) { responses.fetch('200') }
172
+
173
+ it 'will have description' do
174
+ expect(response_200).to include(description: 'When the pancake is found')
175
+ end
176
+
177
+ it 'will have correct content type' do
178
+ expect(response_200.fetch(:content)).to have_key(:'application/json')
179
+ end
180
+
181
+ describe 'schema' do
182
+ let(:schema) { response_200.dig(:content, :'application/json', :schema) }
183
+
184
+ it 'will have object type' do
185
+ expect(schema).to include(type: 'object')
186
+ end
187
+
188
+ it 'will have properties' do
189
+ expect(schema).to have_key :properties
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apiculture
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  - WeTransfer
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-01-27 00:00:00.000000000 Z
12
+ date: 2021-06-28 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mustermann
@@ -191,12 +191,14 @@ files:
191
191
  - lib/apiculture/indifferent_hash.rb
192
192
  - lib/apiculture/markdown_segment.rb
193
193
  - lib/apiculture/method_documentation.rb
194
+ - lib/apiculture/openapi_documentation.rb
194
195
  - lib/apiculture/sinatra_instance_methods.rb
195
196
  - lib/apiculture/timestamp_promise.rb
196
197
  - lib/apiculture/version.rb
197
198
  - spec/apiculture/action_spec.rb
198
199
  - spec/apiculture/app_documentation_spec.rb
199
200
  - spec/apiculture/method_documentation_spec.rb
201
+ - spec/apiculture/openapi_documentation_spec.rb
200
202
  - spec/apiculture_spec.rb
201
203
  - spec/spec_helper.rb
202
204
  homepage: https://github.com/WeTransfer/apiculture
@@ -204,7 +206,7 @@ licenses:
204
206
  - MIT
205
207
  metadata:
206
208
  allowed_push_host: https://rubygems.org
207
- post_install_message:
209
+ post_install_message:
208
210
  rdoc_options: []
209
211
  require_paths:
210
212
  - lib
@@ -219,8 +221,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
219
221
  - !ruby/object:Gem::Version
220
222
  version: '0'
221
223
  requirements: []
222
- rubygems_version: 3.0.3
223
- signing_key:
224
+ rubygems_version: 3.0.9
225
+ signing_key:
224
226
  specification_version: 4
225
227
  summary: Sweet API sauce on top of Rack
226
228
  test_files: []