apiculture 0.1.7 → 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 +4 -4
- data/.gitignore +3 -5
- data/.travis.yml +2 -0
- data/CHANGELOG.md +3 -0
- data/apiculture.gemspec +19 -20
- data/gemfiles/Gemfile.rack-1.x +2 -3
- data/gemfiles/Gemfile.rack-2.x +2 -3
- data/lib/apiculture/app.rb +4 -4
- data/lib/apiculture/sinatra_instance_methods.rb +5 -5
- data/lib/apiculture/version.rb +1 -1
- data/lib/apiculture.rb +39 -39
- metadata +38 -43
- data/spec/apiculture/action_spec.rb +0 -45
- data/spec/apiculture/app_documentation_spec.rb +0 -126
- data/spec/apiculture/method_documentation_spec.rb +0 -102
- data/spec/apiculture/openapi_documentation_spec.rb +0 -197
- data/spec/apiculture_spec.rb +0 -461
- data/spec/spec_helper.rb +0 -15
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d78cc86595f72f0ec4ea08adaa5b545798eb54653aafedb47d3e6923351b3b76
|
4
|
+
data.tar.gz: 532aa3794d65dfd962d4d5984a29678e026b5da43efd1f5ad5dbaea4d4ee6ff1
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
data/CHANGELOG.md
ADDED
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 =
|
7
|
+
s.name = 'apiculture'
|
8
8
|
s.version = Apiculture::VERSION
|
9
9
|
|
10
|
-
s.require_paths = [
|
11
|
-
s.authors = [
|
12
|
-
s.
|
13
|
-
s.
|
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
|
-
|
29
|
-
|
27
|
+
'LICENSE.txt',
|
28
|
+
'README.md'
|
30
29
|
]
|
31
|
-
s.homepage =
|
32
|
-
s.licenses = [
|
33
|
-
s.rubygems_version =
|
34
|
-
s.summary =
|
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
|
44
|
-
s.add_development_dependency
|
45
|
-
s.add_development_dependency
|
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
|
data/gemfiles/Gemfile.rack-1.x
CHANGED
data/gemfiles/Gemfile.rack-2.x
CHANGED
data/lib/apiculture/app.rb
CHANGED
@@ -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
|
@@ -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
|
data/lib/apiculture/version.rb
CHANGED
data/lib/apiculture.rb
CHANGED
@@ -10,40 +10,40 @@ module Apiculture
|
|
10
10
|
require_relative 'apiculture/timestamp_promise'
|
11
11
|
require_relative 'apiculture/app_documentation'
|
12
12
|
require_relative 'apiculture/openapi_documentation'
|
13
|
-
|
13
|
+
|
14
14
|
def self.extended(in_class)
|
15
15
|
in_class.send(:include, SinatraInstanceMethods)
|
16
16
|
super
|
17
17
|
end
|
18
|
-
|
18
|
+
|
19
19
|
IDENTITY_PROC = ->(arg) { arg }
|
20
|
-
|
20
|
+
|
21
21
|
AC_APPLY_TYPECAST_PROC = ->(cast_proc_or_method, v) {
|
22
22
|
cast_proc_or_method.is_a?(Symbol) ? v.public_send(cast_proc_or_method) : cast_proc_or_method.call(v)
|
23
23
|
}
|
24
|
-
|
24
|
+
|
25
25
|
AC_CHECK_PRESENCE_PROC = ->(name_as_string, params) {
|
26
26
|
params.has_key?(name_as_string) or raise MissingParameter.new(name_as_string)
|
27
27
|
}
|
28
|
-
|
28
|
+
|
29
29
|
AC_CHECK_TYPE_PROC = ->(param, value) {
|
30
30
|
param.matchable === value or raise ParameterTypeMismatch.new(param, value.class)
|
31
31
|
}
|
32
|
-
|
32
|
+
|
33
33
|
class Parameter < Struct.new(:name, :description, :required, :matchable, :cast_proc_or_method)
|
34
34
|
# Return Strings since Sinatra prefers string keys for params{}
|
35
35
|
def name_as_string; name.to_s; end
|
36
36
|
end
|
37
|
-
|
37
|
+
|
38
38
|
class RouteParameter < Parameter
|
39
39
|
end
|
40
|
-
|
40
|
+
|
41
41
|
class PossibleResponse < Struct.new(:http_status_code, :description, :jsonable_object_example)
|
42
42
|
def no_body?
|
43
43
|
jsonable_object_example.nil?
|
44
44
|
end
|
45
45
|
end
|
46
|
-
|
46
|
+
|
47
47
|
# Indicates where this API will be mounted. This is only used
|
48
48
|
# for the generated documentation. In general, this should match
|
49
49
|
# the SCRIPT_NAME of the Sinatra application when it will be called.
|
@@ -61,13 +61,13 @@ module Apiculture
|
|
61
61
|
def mounted_at(path)
|
62
62
|
@apiculture_mounted_at = path.to_s.gsub(/\/$/, '')
|
63
63
|
end
|
64
|
-
|
64
|
+
|
65
65
|
# Inserts the generation timestamp into the documentation at this point.
|
66
66
|
# The timestamp will be not very precise (to the minute) and in UTC time
|
67
67
|
def documentation_build_time!
|
68
68
|
apiculture_stack << Apiculture::TimestampPromise
|
69
69
|
end
|
70
|
-
|
70
|
+
|
71
71
|
# Inserts a literal Markdown string into the documentation at this point.
|
72
72
|
# For instance, if used after an API method declaration, it will insert
|
73
73
|
# the header between the API methods in the doc.
|
@@ -82,7 +82,7 @@ module Apiculture
|
|
82
82
|
def markdown_string(str)
|
83
83
|
apiculture_stack << MarkdownSegment.new(str)
|
84
84
|
end
|
85
|
-
|
85
|
+
|
86
86
|
# Inserts the contents of the file at +path+ into the documentation, using +markdown_string+.
|
87
87
|
# For instance, if used after an API method declaration, it will insert
|
88
88
|
# the header between the API methods in the doc.
|
@@ -95,25 +95,25 @@ module Apiculture
|
|
95
95
|
md = File.read(path_to_markdown).encode(Encoding::UTF_8)
|
96
96
|
markdown_string(md)
|
97
97
|
end
|
98
|
-
|
98
|
+
|
99
99
|
# Describe the API method that is going to be defined
|
100
100
|
def desc(action_description)
|
101
101
|
@apiculture_action_definition ||= ActionDefinition.new
|
102
102
|
@apiculture_action_definition.description = action_description.to_s
|
103
103
|
end
|
104
|
-
|
104
|
+
|
105
105
|
# Add an optional parameter for the API call
|
106
106
|
def param(name, description, matchable, cast: IDENTITY_PROC)
|
107
107
|
@apiculture_action_definition ||= ActionDefinition.new
|
108
108
|
@apiculture_action_definition.parameters << Parameter.new(name, description, required=false, matchable, cast)
|
109
109
|
end
|
110
|
-
|
110
|
+
|
111
111
|
# Add a requred parameter for the API call
|
112
112
|
def required_param(name, description, matchable, cast: IDENTITY_PROC)
|
113
113
|
@apiculture_action_definition ||= ActionDefinition.new
|
114
114
|
@apiculture_action_definition.parameters << Parameter.new(name, description, required=true, matchable, cast)
|
115
115
|
end
|
116
|
-
|
116
|
+
|
117
117
|
# Describe a parameter that has to be included in the URL of the API call.
|
118
118
|
# Route parameters are always required, and all the parameters specified
|
119
119
|
# using +route_param+ should also be included in the path given for the route
|
@@ -122,28 +122,28 @@ module Apiculture
|
|
122
122
|
@apiculture_action_definition ||= ActionDefinition.new
|
123
123
|
@apiculture_action_definition.route_parameters << RouteParameter.new(name, description, required=false, matchable, cast)
|
124
124
|
end
|
125
|
-
|
125
|
+
|
126
126
|
# Add a possible response, specifying the code and the JSON Response by example.
|
127
127
|
# Multiple response packages can be specified.
|
128
128
|
def responds_with(http_status, description, example_jsonable_object = nil)
|
129
129
|
@apiculture_action_definition ||= ActionDefinition.new
|
130
130
|
@apiculture_action_definition.responses << PossibleResponse.new(http_status, description, example_jsonable_object)
|
131
131
|
end
|
132
|
-
|
132
|
+
|
133
133
|
DefinitionError = Class.new(StandardError)
|
134
134
|
ValidationError = Class.new(StandardError)
|
135
|
-
|
135
|
+
|
136
136
|
class RouteParameterNotInPath < DefinitionError; end
|
137
137
|
class ReservedParameter < DefinitionError; end
|
138
138
|
class ConflictingParameter < DefinitionError; end
|
139
|
-
|
139
|
+
|
140
140
|
# Gets raised when a parameter is missing
|
141
141
|
class MissingParameter < ValidationError
|
142
142
|
def initialize(parameter_name)
|
143
143
|
super "Missing parameter :#{parameter_name}"
|
144
144
|
end
|
145
145
|
end
|
146
|
-
|
146
|
+
|
147
147
|
# Gets raised when a parameter is supplied and has a wrong type
|
148
148
|
class ParameterTypeMismatch < ValidationError
|
149
149
|
def initialize(ac_parameter, received_ruby_type)
|
@@ -152,7 +152,7 @@ module Apiculture
|
|
152
152
|
super "Received #{received_type}, expected #{expected_type.inspect} for :#{parameter_name}"
|
153
153
|
end
|
154
154
|
end
|
155
|
-
|
155
|
+
|
156
156
|
# Returns a Proc that calls the strong parameters to check the presence/types
|
157
157
|
def parametric_validator_proc_from(parametric_validators, implicitly_defined_route_parameter_names)
|
158
158
|
required_params = parametric_validators.select{|e| e.required }
|
@@ -164,23 +164,23 @@ module Apiculture
|
|
164
164
|
parametric_validators.each do |param|
|
165
165
|
param_name = param.name_as_string
|
166
166
|
next unless params.has_key?(param_name) # this is checked via required_params
|
167
|
-
|
167
|
+
|
168
168
|
# Apply the type cast and save it (since using our override we can mutate the params)
|
169
169
|
value_after_type_cast = AC_APPLY_TYPECAST_PROC.call(param.cast_proc_or_method, params[param_name])
|
170
170
|
params[param_name] = value_after_type_cast
|
171
|
-
|
171
|
+
|
172
172
|
# Ensure the typecast value adheres to the enforced Ruby type
|
173
173
|
AC_CHECK_TYPE_PROC.call(param, params[param_name])
|
174
174
|
end
|
175
|
-
|
176
|
-
# The following only applies if the app does not use strong_parameters -
|
175
|
+
|
176
|
+
# The following only applies if the app does not use strong_parameters -
|
177
177
|
# this makes use of parameter mutability again to kill the parameters that are not permitted
|
178
178
|
# or mentioned in the API specification. We need to keep the params which are specified in the
|
179
179
|
# route but not documented via Apiculture though
|
180
180
|
unexpected_parameters = Set.new(params.keys.map(&:to_s)) -
|
181
181
|
Set.new(parametric_validators.map(&:name).map(&:to_s)) -
|
182
182
|
Set.new(implicitly_defined_route_parameter_names.map(&:to_s))
|
183
|
-
|
183
|
+
|
184
184
|
unexpected_parameters.each do | parameter_to_discard |
|
185
185
|
# TODO: raise or record a warning
|
186
186
|
if env['rack.logger'].respond_to?(:warn)
|
@@ -190,7 +190,7 @@ module Apiculture
|
|
190
190
|
end
|
191
191
|
}
|
192
192
|
end
|
193
|
-
|
193
|
+
|
194
194
|
# Serve the documentation for the API at the given URL
|
195
195
|
def serve_api_documentation_at(url)
|
196
196
|
get(url) do
|
@@ -198,7 +198,7 @@ module Apiculture
|
|
198
198
|
self.class.api_documentation.to_html
|
199
199
|
end
|
200
200
|
end
|
201
|
-
|
201
|
+
|
202
202
|
# Returns an +AppDocumentation+ object for all actions defined so far.
|
203
203
|
#
|
204
204
|
# MyApi.api_documentation.to_markdown #=> "..."
|
@@ -213,7 +213,7 @@ module Apiculture
|
|
213
213
|
action_def = (@apiculture_action_definition || ActionDefinition.new)
|
214
214
|
action_def.http_verb = http_verb
|
215
215
|
action_def.path = path
|
216
|
-
|
216
|
+
|
217
217
|
# Ensure no reserved Sinatra parameters are used
|
218
218
|
all_parameter_names = action_def.all_parameter_names_as_strings
|
219
219
|
%w( splat captures ).each do | reserved_param |
|
@@ -221,34 +221,34 @@ module Apiculture
|
|
221
221
|
raise ReservedParameter.new(":#{reserved_param} is a reserved magic parameter name in Sinatra")
|
222
222
|
end
|
223
223
|
end
|
224
|
-
|
224
|
+
|
225
225
|
# Ensure no conflations between route/req params
|
226
226
|
seen_params = {}
|
227
|
-
all_parameter_names.each do |e|
|
227
|
+
all_parameter_names.each do |e|
|
228
228
|
if seen_params[e]
|
229
|
-
raise ConflictingParameter.new(":#{e} mentioned twice as a possible parameter. Note that URL" +
|
229
|
+
raise ConflictingParameter.new(":#{e} mentioned twice as a possible parameter. Note that URL" +
|
230
230
|
" parameters and request parameters share a namespace.")
|
231
231
|
else
|
232
232
|
seen_params[e] = true
|
233
233
|
end
|
234
234
|
end
|
235
|
-
|
235
|
+
|
236
236
|
# Ensure the path has the route parameters that were predeclared
|
237
237
|
action_def.route_parameters.map(&:name).each do | route_parameter_key |
|
238
238
|
unless path.include?(':%s' % route_parameter_key)
|
239
239
|
raise RouteParameterNotInPath.new("Parameter :#{route_parameter_key} not present in path #{path.inspect}")
|
240
240
|
end
|
241
241
|
end
|
242
|
-
|
242
|
+
|
243
243
|
# TODO: ensure all route parameters are documented
|
244
|
-
|
244
|
+
|
245
245
|
# Pick out all the defined parameters and set up a block that can validate them
|
246
246
|
# when the action is called. With that, set up the actual Sinatra method that will
|
247
247
|
# respond to the request. We take care to preserve all the params that have NOT been documented
|
248
248
|
# using Apiculture but _were_ in fact specified in the actual path.
|
249
249
|
route_parameter_names = path.scan(/:([^:\/]+)/).flatten.map(&:to_sym)
|
250
250
|
parametric_checker_proc = parametric_validator_proc_from(action_def.parameters + action_def.route_parameters, route_parameter_names)
|
251
|
-
public_send(http_verb, path, options) do |*matched_sinatra_route_params|
|
251
|
+
public_send(http_verb, path, **options) do |*matched_sinatra_route_params|
|
252
252
|
# Extract all the parameter names from the route path as given to the method
|
253
253
|
route_parameters = Hash[route_parameter_names.zip(matched_sinatra_route_params)]
|
254
254
|
|
@@ -269,13 +269,13 @@ module Apiculture
|
|
269
269
|
# Execute the original action via instance_exec, passing along the route args
|
270
270
|
instance_exec(*route_parameters.values, &blk)
|
271
271
|
end
|
272
|
-
|
272
|
+
|
273
273
|
# Reset for the subsequent action definition
|
274
274
|
@apiculture_action_definition = ActionDefinition.new
|
275
275
|
# and store the just defined action for future use
|
276
276
|
apiculture_stack << action_def
|
277
277
|
end
|
278
|
-
|
278
|
+
|
279
279
|
def apiculture_stack
|
280
280
|
@apiculture_actions_and_docs ||= []
|
281
281
|
@apiculture_actions_and_docs
|