apiculture 0.1.7 → 0.2.1

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: 21e9fada9f8ba88c058f19789d597059bb30b055fe92c10e27953c5df6296758
4
- data.tar.gz: 7b3c02a5587b6976a2a1a7b11656834824e32887b9dde3922542687fcbe63c71
3
+ metadata.gz: bd3d6da6eca1e6a1c63d824bfbed2d43d2a74032f966dc6f17df09c6458548c8
4
+ data.tar.gz: 53581729f4b81574b5bc400786509cfa6f9364fde56486611ae8eab8c3370f8e
5
5
  SHA512:
6
- metadata.gz: b0818c0fd6c04efa47f0cabcd0927c13516bd293d7486fea8750b7325d260208b3134432ebf43fbc9d7f0321a8ff7b8e5ea07e6f679e59b1df62e0f5f105f1b7
7
- data.tar.gz: 853ca12aefe6005ae08432d29f902f6a1584252db87663c08c96a00f47d7b771108e2485f39e7400615e8f54243f68f6abf2e8406a963def5e3821c9ee62a6fc
6
+ metadata.gz: c2f325148ce9a454206d278f665b583fbd896b442d5f07eecfef481a43143299001c3097eceaf37e2d61866f8f06e5523677ffcad031a9a5811419ce7f84ee9e
7
+ data.tar.gz: 97159d92a46fef3e7c093c87ce4b80e783a4610c073857f80c74205ddfcabc7d6b019d3d29b1b2a66d5fd2a6fff92a2fef9f2d3c910dc62478613d42e692dabe
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,7 @@
1
+ ## 0.2.0
2
+ * Add ruby 3 support
3
+ * Bump `mustermann` dependency to '~> 3'
4
+
5
+
6
+ ## 0.2.1
7
+ * Add support for nested fields
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
@@ -8,9 +8,9 @@ module OpenApiDocumentation
8
8
  @paths = chunks.select { |chunk| chunk.respond_to?(:http_verb) }
9
9
  @data = {
10
10
  openapi: '3.0.0',
11
- info: {
12
- title: @app.to_s,
13
- version: '0.0.1',
11
+ info: {
12
+ title: @app.to_s,
13
+ version: '0.0.1',
14
14
  description: @app.to_s + " " + chunks.select { |chunk| chunk.respond_to?(:to_markdown) }.map(&:to_markdown).join("\n")
15
15
  },
16
16
  tags: []
@@ -27,7 +27,7 @@ module OpenApiDocumentation
27
27
  end
28
28
 
29
29
  def spec
30
- @data
30
+ @data
31
31
  end
32
32
 
33
33
  private
@@ -137,7 +137,7 @@ module OpenApiDocumentation
137
137
 
138
138
  def build_request_body
139
139
  return nil if VERBS_WITHOUT_BODY.include?(@path.http_verb)
140
-
140
+
141
141
  body_params = Hash[ @path.parameters.collect do |parameter|
142
142
  [parameter.name, {
143
143
  type: Util.map_type(parameter.matchable),
@@ -171,7 +171,7 @@ module OpenApiDocumentation
171
171
  unless response.jsonable_object_example.nil? || response.jsonable_object_example.empty?
172
172
  _response[:content] = {
173
173
  'application/json': {
174
- schema:
174
+ schema:
175
175
  { type: 'object',
176
176
  properties: Util.response_to_schema(response.jsonable_object_example) }
177
177
  }
@@ -198,15 +198,27 @@ module OpenApiDocumentation
198
198
  }.freeze
199
199
 
200
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
- }
201
+ case response
202
+ when NilClass
203
+ when String
204
+ { type: 'string', example: response }
205
+ when Integer
206
+ { type: 'integer', example: response }
207
+ when Float
208
+ { type: 'float', example: response }
209
+ when Array
210
+ if response.empty?
211
+ { type: 'array', items: {} }
212
+ else
213
+ { type: 'array', items: response.map { |elem| response_to_schema(elem) } }
214
+ end
215
+ when Hash
216
+ response.each_with_object({}) do |(key, val), schema_hash|
217
+ schema_hash[key] = response_to_schema(val)
218
+ end
219
+ else
220
+ { type: response.class.name.downcase, example: response.to_s }
208
221
  end
209
- schema
210
222
  end
211
223
 
212
224
  def self.map_type(type)
@@ -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.7'
2
+ VERSION = '0.2.1'.freeze
3
3
  end
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