apiculture 0.1.6 → 0.1.7

Sign up to get free protection for your applications and to get access to all the features.
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: []