apiculture 0.1.6 → 0.2.0

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: d78cc86595f72f0ec4ea08adaa5b545798eb54653aafedb47d3e6923351b3b76
4
+ data.tar.gz: 532aa3794d65dfd962d4d5984a29678e026b5da43efd1f5ad5dbaea4d4ee6ff1
5
5
  SHA512:
6
- metadata.gz: 39564495733b83ca4ce0e1963b470d9836e5ce549827f93689bebfe4f5f8e57fb368497a407142f1c6c78143ad6c56a6a5a4f74ba29bdf199704bce58d720885
7
- data.tar.gz: 6c1e15fe7e3c009349d17cf59d5686b81ccc6e6c94724a89b343b404afd3c013ee8505bebe75c3228774215f7065c1faf11872589de6e6b78abafe8d462f78f7
6
+ metadata.gz: b351b52e4a10c1c7ee2b7d4f7b5c8f63d4ed8b28f65348ebf63ec0f064ccb55b9bfd6cad6f21d49c34867436dfc50036e67f4d8fdb206f3525581346ee7db3a8
7
+ data.tar.gz: 9a0d97711023461335d4237f44e077daf2b1669c9509bb53dcd79571fb8a157188fb06b9d93db28ab9df69f170d152c50746ce8c27f73eb2af7781963d8e26bf
data/.gitignore CHANGED
@@ -1,7 +1,3 @@
1
- # rcov generated
2
- coverage
3
- coverage.data
4
-
5
1
  # rdoc generated
6
2
  rdoc
7
3
 
@@ -16,7 +12,7 @@ Gemfile.lock
16
12
  # jeweler generated
17
13
  pkg
18
14
 
19
- # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
15
+ # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore:
20
16
  #
21
17
  # * Create a file at ~/.gitignore
22
18
  # * Include files you want ignored
@@ -48,3 +44,5 @@ pkg
48
44
 
49
45
  # For rubinius:
50
46
  #*.rbc
47
+
48
+ .ruby-version
data/.travis.yml CHANGED
@@ -7,5 +7,7 @@ rvm:
7
7
  - 2.5
8
8
  - 2.6
9
9
  - 2.7
10
+ - 3.0
11
+ - 3.2
10
12
  sudo: false
11
13
  cache: bundler
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## 0.2.0
2
+ * Add ruby 3 support
3
+ * Bump `mustermann` dependency to '~> 3'
data/apiculture.gemspec CHANGED
@@ -4,14 +4,13 @@ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
4
  require 'apiculture/version'
5
5
 
6
6
  Gem::Specification.new do |s|
7
- s.name = "apiculture"
7
+ s.name = 'apiculture'
8
8
  s.version = Apiculture::VERSION
9
9
 
10
- s.require_paths = ["lib"]
11
- s.authors = ["Julik Tarkhanov", "WeTransfer"]
12
- s.homepage = 'http://github.com/wetransfer/zip_tricks'
13
- s.description = "A toolkit for building REST APIs on top of Rack"
14
- s.email = "me@julik.nl"
10
+ s.require_paths = ['lib']
11
+ s.authors = ['Julik Tarkhanov', 'WeTransfer']
12
+ s.description = 'A toolkit for building REST APIs on top of Rack'
13
+ s.email = 'me@julik.nl'
15
14
 
16
15
  # Prevent pushing this gem to RubyGems.org.
17
16
  # To allow pushes either set the 'allowed_push_host'
@@ -23,26 +22,26 @@ Gem::Specification.new do |s|
23
22
  'public gem pushes.'
24
23
  end
25
24
 
26
- s.files = `git ls-files -z`.split("\x0")
25
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^spec/}) }
27
26
  s.extra_rdoc_files = [
28
- "LICENSE.txt",
29
- "README.md"
27
+ 'LICENSE.txt',
28
+ 'README.md'
30
29
  ]
31
- s.homepage = "https://github.com/WeTransfer/apiculture"
32
- s.licenses = ["MIT"]
33
- s.rubygems_version = "2.4.5.1"
34
- s.summary = "Sweet API sauce on top of Rack"
30
+ s.homepage = 'https://github.com/WeTransfer/apiculture'
31
+ s.licenses = ['MIT']
32
+ s.rubygems_version = '2.4.5.1'
33
+ s.summary = 'Sweet API sauce on top of Rack'
35
34
 
36
- s.add_runtime_dependency 'mustermann', '~> 1'
37
35
  s.add_runtime_dependency 'builder', '~> 3'
38
- s.add_runtime_dependency 'rdiscount', '~> 2'
39
36
  s.add_runtime_dependency 'github-markup', '~> 3'
40
37
  s.add_runtime_dependency 'mustache', '~> 1'
38
+ s.add_runtime_dependency 'mustermann', '~> 3'
39
+ s.add_runtime_dependency 'rdiscount', '~> 2'
41
40
 
41
+ s.add_development_dependency 'bundler', '~> 2.0'
42
+ s.add_development_dependency 'cgi'
42
43
  s.add_development_dependency 'rack-test'
43
- s.add_development_dependency "rspec", "~> 3"
44
- s.add_development_dependency "rdoc", "~> 6.0"
45
- s.add_development_dependency "rake"
46
- s.add_development_dependency "bundler", "~> 1.0"
47
- s.add_development_dependency "simplecov", ">= 0"
44
+ s.add_development_dependency 'rake'
45
+ s.add_development_dependency 'rdoc', '~> 6.0'
46
+ s.add_development_dependency 'rspec', '~> 3'
48
47
  end
@@ -9,9 +9,8 @@ gem 'mustache', '~> 1'
9
9
 
10
10
  group :development do
11
11
  gem 'rack-test'
12
- gem "rspec", "~> 3.1", '< 3.2'
12
+ gem "rspec", "~> 3"
13
13
  gem "rdoc", "~> 6.0"
14
- gem "rake", "~> 10"
15
- gem "simplecov", ">= 0"
14
+ gem "rake"
16
15
  gem "bundler"
17
16
  end
@@ -9,9 +9,8 @@ gem 'mustache', '~> 1'
9
9
 
10
10
  group :development do
11
11
  gem 'rack-test'
12
- gem "rspec", "~> 3.1", '< 3.2'
12
+ gem "rspec", "~> 3"
13
13
  gem "rdoc", "~> 6.0"
14
- gem "rake", "~> 10"
15
- gem "simplecov", ">= 0"
14
+ gem "rake"
16
15
  gem "bundler"
17
16
  end
@@ -13,19 +13,19 @@ class Apiculture::App
13
13
  end
14
14
 
15
15
  def get(url, **options, &handler_blk)
16
- define_action :get, url, options, &handler_blk
16
+ define_action :get, url, **options, &handler_blk
17
17
  end
18
18
 
19
19
  def post(url, **options, &handler_blk)
20
- define_action :post, url, options, &handler_blk
20
+ define_action :post, url, **options, &handler_blk
21
21
  end
22
22
 
23
23
  def put(url, **options, &handler_blk)
24
- define_action :put, url, options, &handler_blk
24
+ define_action :put, url, **options, &handler_blk
25
25
  end
26
26
 
27
27
  def delete(url, **options, &handler_blk)
28
- define_action :delete, url, options, &handler_blk
28
+ define_action :delete, url, **options, &handler_blk
29
29
  end
30
30
 
31
31
  def actions
@@ -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
@@ -4,7 +4,7 @@ require 'json'
4
4
  module Apiculture::SinatraInstanceMethods
5
5
  NEWLINE = "\n"
6
6
  DEFAULT = :__default__
7
-
7
+
8
8
  # Convert the given structure to JSON, set the content-type and
9
9
  # return the JSON string
10
10
  def json_response(structure, status: DEFAULT)
@@ -12,7 +12,7 @@ module Apiculture::SinatraInstanceMethods
12
12
  status(status) unless status == DEFAULT
13
13
  JSON.pretty_generate(structure)
14
14
  end
15
-
15
+
16
16
  # Bail out from an action by sending a halt() via Sinatra. Is most useful for
17
17
  # handling access denied, invalid resource and other types of situations
18
18
  # where you don't want the request to continue, but still would like to
@@ -20,10 +20,10 @@ module Apiculture::SinatraInstanceMethods
20
20
  # with it's own JSON means.
21
21
  def json_halt(with_error_message, status: 400, **attrs_for_json_response)
22
22
  # Pretty-print + newline to be terminal-friendly
23
- err_str = JSON.pretty_generate({error: with_error_message}.merge(attrs_for_json_response)) + NEWLINE
23
+ err_str = JSON.pretty_generate({error: with_error_message}.merge(**attrs_for_json_response)) + NEWLINE
24
24
  halt status, {'Content-Type' => 'application/json'}, [err_str]
25
25
  end
26
-
26
+
27
27
  # Handles the given action via the given class, passing it the instance variables
28
28
  # given in the keyword arguments
29
29
  def action_result(action_class, **action_ivars)
@@ -31,7 +31,7 @@ module Apiculture::SinatraInstanceMethods
31
31
  unless call_result.is_a?(Array) || call_result.is_a?(Hash) || (call_result.nil? && @status == 204)
32
32
  raise "Action result should be an Array, a Hash or it can be nil but only if status is 204, instead it was a #{call_result.class}"
33
33
  end
34
-
34
+
35
35
  json_response call_result if call_result
36
36
  end
37
37
  end
@@ -1,3 +1,3 @@
1
1
  module Apiculture
2
- VERSION = '0.1.6'
2
+ VERSION = '0.2.0'.freeze
3
3
  end