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 +4 -4
- data/lib/apiculture.rb +2 -1
- data/lib/apiculture/app_documentation.rb +4 -0
- data/lib/apiculture/openapi_documentation.rb +224 -0
- data/lib/apiculture/version.rb +1 -1
- data/spec/apiculture/openapi_documentation_spec.rb +197 -0
- metadata +8 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 21e9fada9f8ba88c058f19789d597059bb30b055fe92c10e27953c5df6296758
|
4
|
+
data.tar.gz: 7b3c02a5587b6976a2a1a7b11656834824e32887b9dde3922542687fcbe63c71
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/apiculture/version.rb
CHANGED
@@ -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.
|
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-
|
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.
|
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: []
|