apiculture 0.1.6 → 0.2.0

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: 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