haveapi 0.27.2 → 0.28.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/haveapi.gemspec +1 -1
  4. data/lib/haveapi/action.rb +125 -36
  5. data/lib/haveapi/actions/paginable.rb +3 -1
  6. data/lib/haveapi/authentication/basic/provider.rb +2 -0
  7. data/lib/haveapi/authentication/chain.rb +11 -7
  8. data/lib/haveapi/authentication/oauth2/config.rb +25 -3
  9. data/lib/haveapi/authentication/oauth2/provider.rb +92 -11
  10. data/lib/haveapi/authentication/oauth2/revoke_endpoint.rb +44 -3
  11. data/lib/haveapi/authentication/token/provider.rb +53 -15
  12. data/lib/haveapi/authorization.rb +42 -18
  13. data/lib/haveapi/client_examples/php_client.rb +1 -1
  14. data/lib/haveapi/client_examples/ruby_client.rb +1 -1
  15. data/lib/haveapi/context.rb +10 -4
  16. data/lib/haveapi/example.rb +15 -16
  17. data/lib/haveapi/extensions/action_exceptions.rb +6 -6
  18. data/lib/haveapi/model_adapters/active_record.rb +150 -71
  19. data/lib/haveapi/model_adapters/hash.rb +1 -1
  20. data/lib/haveapi/parameters/resource.rb +50 -6
  21. data/lib/haveapi/parameters/typed.rb +40 -13
  22. data/lib/haveapi/params.rb +27 -8
  23. data/lib/haveapi/resource.rb +4 -1
  24. data/lib/haveapi/resources/action_state.rb +13 -5
  25. data/lib/haveapi/route.rb +2 -2
  26. data/lib/haveapi/server.rb +137 -45
  27. data/lib/haveapi/validator.rb +2 -2
  28. data/lib/haveapi/validator_chain.rb +1 -0
  29. data/lib/haveapi/validators/confirmation.rb +1 -0
  30. data/lib/haveapi/validators/format.rb +6 -2
  31. data/lib/haveapi/validators/length.rb +2 -0
  32. data/lib/haveapi/validators/numericality.rb +2 -0
  33. data/lib/haveapi/validators/presence.rb +1 -1
  34. data/lib/haveapi/version.rb +1 -1
  35. data/lib/haveapi/views/version_page/client_auth.erb +1 -1
  36. data/lib/haveapi/views/version_page/client_example.erb +3 -3
  37. data/lib/haveapi/views/version_page/client_init.erb +1 -1
  38. data/lib/haveapi/views/version_page.erb +2 -2
  39. data/lib/haveapi/views/version_sidebar.erb +4 -2
  40. data/spec/action/authorize_spec.rb +99 -0
  41. data/spec/action/runtime_spec.rb +426 -0
  42. data/spec/action_state_spec.rb +52 -0
  43. data/spec/authentication/basic_spec.rb +29 -0
  44. data/spec/authentication/oauth2_spec.rb +329 -0
  45. data/spec/authentication/token_spec.rb +195 -0
  46. data/spec/authentication/token_version_routes_spec.rb +164 -0
  47. data/spec/authorization_spec.rb +66 -0
  48. data/spec/documentation/auth_filtering_spec.rb +195 -1
  49. data/spec/documentation/current_user_html_escaping_spec.rb +47 -0
  50. data/spec/documentation/examples_spec.rb +97 -0
  51. data/spec/documentation/host_html_escaping_spec.rb +41 -0
  52. data/spec/documentation_spec.rb +13 -0
  53. data/spec/extensions/action_exceptions_spec.rb +30 -0
  54. data/spec/model_adapters/active_record_spec.rb +408 -3
  55. data/spec/parameters/typed_spec.rb +75 -7
  56. data/spec/params_spec.rb +41 -0
  57. data/spec/server/integration_spec.rb +90 -0
  58. data/spec/validator_chain_spec.rb +39 -0
  59. data/spec/validators/confirmation_spec.rb +14 -0
  60. data/spec/validators/format_spec.rb +7 -0
  61. data/spec/validators/length_spec.rb +6 -0
  62. data/spec/validators/numericality_spec.rb +7 -0
  63. data/spec/validators/presence_spec.rb +2 -0
  64. data/test_support/client_test_api.rb +31 -3
  65. metadata +8 -4
  66. data/shell.nix +0 -20
@@ -5,7 +5,7 @@ module HaveAPI::Parameters
5
5
 
6
6
  def initialize(resource, name: nil, label: nil, desc: nil,
7
7
  choices: nil, value_id: :id, value_label: :label, required: nil,
8
- db_name: nil, fetch: nil)
8
+ db_name: nil, fetch: nil, nullable: nil)
9
9
  @resource = resource
10
10
  @resource_path = build_resource_path(resource)
11
11
  @name = name || resource.resource_name.underscore.to_sym
@@ -15,6 +15,7 @@ module HaveAPI::Parameters
15
15
  @value_id = value_id
16
16
  @value_label = value_label
17
17
  @required = required
18
+ @nullable = nullable
18
19
  @db_name = db_name
19
20
  @extra = {
20
21
  fetch:
@@ -33,6 +34,10 @@ module HaveAPI::Parameters
33
34
  !@required
34
35
  end
35
36
 
37
+ def nullable?
38
+ @nullable == true && optional?
39
+ end
40
+
36
41
  def show_action
37
42
  @resource::Show
38
43
  end
@@ -56,6 +61,7 @@ module HaveAPI::Parameters
56
61
 
57
62
  {
58
63
  required: required?,
64
+ nullable: nullable?,
59
65
  label: @label,
60
66
  description: @desc,
61
67
  type: 'Resource',
@@ -91,17 +97,27 @@ module HaveAPI::Parameters
91
97
  attrs.each { |k, v| instance_variable_set("@#{k}", v) }
92
98
  end
93
99
 
94
- def clean(raw)
100
+ def clean(raw, context = nil)
101
+ if raw.nil?
102
+ return nil if nullable?
103
+
104
+ raise HaveAPI::ValidationError, 'cannot be null'
105
+ end
106
+
95
107
  if raw.is_a?(String)
96
- stripped = raw.strip
97
- return nil if stripped.empty? && optional?
108
+ stripped = strip_string(raw)
109
+ return nil if stripped.empty? && nullable?
98
110
  end
99
111
 
100
- extra = @extra.merge(optional: optional?)
112
+ extra = @extra.merge(optional: optional?, nullable: nullable?)
101
113
 
102
- ::HaveAPI::ModelAdapter.for(
114
+ ret = ::HaveAPI::ModelAdapter.for(
103
115
  show_action.input.layout, @resource.model
104
116
  ).input_clean(@resource.model, raw, extra)
117
+
118
+ authorize_record!(ret, context)
119
+
120
+ ret
105
121
  end
106
122
 
107
123
  def validate(v, params)
@@ -114,6 +130,12 @@ module HaveAPI::Parameters
114
130
 
115
131
  private
116
132
 
133
+ def strip_string(value)
134
+ value.strip
135
+ rescue ArgumentError, Encoding::CompatibilityError
136
+ raise HaveAPI::ValidationError, 'invalid string encoding'
137
+ end
138
+
117
139
  def build_resource_path(r)
118
140
  path = []
119
141
  top_module = Kernel
@@ -132,5 +154,27 @@ module HaveAPI::Parameters
132
154
 
133
155
  path
134
156
  end
157
+
158
+ def authorize_record!(record, context)
159
+ return if record.nil? || context.nil?
160
+ return unless show_action.authorization
161
+
162
+ path = show_action.build_route('')
163
+ path_params = show_action.path_params(path, show_action.resolve_path_params(record))
164
+ child_context = HaveAPI::Context.new(
165
+ context.server,
166
+ version: context.version,
167
+ request: context.request,
168
+ action: show_action,
169
+ path:,
170
+ params: path_params,
171
+ user: context.current_user,
172
+ endpoint: context.endpoint
173
+ )
174
+ action = show_action.new(context.request, context.version, path_params, nil, child_context)
175
+ return if action.authorized?(context.current_user)
176
+
177
+ raise HaveAPI::ValidationError, 'resource not found'
178
+ end
135
179
  end
136
180
  end
@@ -1,8 +1,9 @@
1
1
  require 'date'
2
+ require 'time'
2
3
 
3
4
  module HaveAPI::Parameters
4
5
  class Typed
5
- ATTRIBUTES = %i[label desc type db_name default fill clean protected load_validators].freeze
6
+ ATTRIBUTES = %i[label desc type db_name default fill clean protected load_validators nullable].freeze
6
7
 
7
8
  attr_reader :name, :label, :desc, :type, :default
8
9
 
@@ -36,6 +37,10 @@ module HaveAPI::Parameters
36
37
  !required?
37
38
  end
38
39
 
40
+ def nullable?
41
+ @nullable == true && optional?
42
+ end
43
+
39
44
  def fill?
40
45
  @fill
41
46
  end
@@ -47,6 +52,7 @@ module HaveAPI::Parameters
47
52
  def describe(context)
48
53
  {
49
54
  required: required?,
55
+ nullable: nullable?,
50
56
  label: @label,
51
57
  description: @desc,
52
58
  type: @type ? @type.to_s : String.to_s,
@@ -73,17 +79,20 @@ module HaveAPI::Parameters
73
79
  end
74
80
 
75
81
  def clean(raw)
76
- return instance_exec(raw, &@clean) if @clean
82
+ return validate_cleaned_value(instance_exec(raw, &@clean)) if @clean
77
83
 
78
- if raw.is_a?(String)
79
- stripped = raw.strip
80
- return nil if stripped.empty? && optional?
84
+ if raw.nil?
85
+ return nil if nullable?
86
+
87
+ raise HaveAPI::ValidationError, 'cannot be null'
81
88
  end
82
89
 
83
- if raw.nil?
84
- @default
90
+ if raw.is_a?(String)
91
+ stripped = strip_string(raw)
92
+ return nil if stripped.empty? && nullable?
93
+ end
85
94
 
86
- elsif @type.nil?
95
+ if @type.nil?
87
96
  nil
88
97
 
89
98
  elsif @type == Integer
@@ -136,6 +145,20 @@ module HaveAPI::Parameters
136
145
 
137
146
  private
138
147
 
148
+ def validate_cleaned_value(value)
149
+ if value.nil? && !nullable?
150
+ raise HaveAPI::ValidationError, 'cannot be null'
151
+ end
152
+
153
+ value
154
+ end
155
+
156
+ def strip_string(value)
157
+ value.strip
158
+ rescue ArgumentError, Encoding::CompatibilityError
159
+ raise HaveAPI::ValidationError, 'invalid string encoding'
160
+ end
161
+
139
162
  def coerce_integer(raw)
140
163
  case raw
141
164
  when Integer
@@ -147,7 +170,7 @@ module HaveAPI::Parameters
147
170
 
148
171
  raw.to_i
149
172
  when String
150
- s = raw.strip
173
+ s = strip_string(raw)
151
174
 
152
175
  if s.empty? || !s.match?(/\A[+-]?\d+\z/)
153
176
  raise HaveAPI::ValidationError, "not a valid integer #{raw.inspect}"
@@ -164,7 +187,7 @@ module HaveAPI::Parameters
164
187
  f = raw.to_f
165
188
 
166
189
  elsif raw.is_a?(String)
167
- s = raw.strip
190
+ s = strip_string(raw)
168
191
  raise HaveAPI::ValidationError, "not a valid float #{raw.inspect}" if s.empty?
169
192
 
170
193
  begin
@@ -191,7 +214,7 @@ module HaveAPI::Parameters
191
214
  return true if raw == 1
192
215
 
193
216
  elsif raw.is_a?(String)
194
- s = raw.strip
217
+ s = strip_string(raw)
195
218
  raise HaveAPI::ValidationError, "not a valid boolean #{raw.inspect}" if s.empty?
196
219
 
197
220
  return true if %w[true t yes y 1].include?(s.downcase)
@@ -202,12 +225,16 @@ module HaveAPI::Parameters
202
225
  end
203
226
 
204
227
  def coerce_datetime(raw)
205
- if raw.is_a?(String) && raw.strip.empty?
228
+ unless raw.is_a?(String)
229
+ raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
230
+ end
231
+
232
+ if strip_string(raw).empty?
206
233
  raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
207
234
  end
208
235
 
209
236
  DateTime.iso8601(raw).to_time
210
- rescue ArgumentError
237
+ rescue ArgumentError, TypeError
211
238
  raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
212
239
  end
213
240
 
@@ -134,12 +134,12 @@ module HaveAPI
134
134
 
135
135
  if include
136
136
  @include ||= []
137
- @include.concat(include)
137
+ @include.concat(normalize_names(include))
138
138
  end
139
139
 
140
140
  if exclude
141
141
  @exclude ||= []
142
- @exclude.concat(exclude)
142
+ @exclude.concat(normalize_names(exclude))
143
143
  end
144
144
 
145
145
  instance_eval(&block)
@@ -209,10 +209,17 @@ module HaveAPI
209
209
  # First step of validation. Check if input is in correct namespace
210
210
  # and has a correct layout.
211
211
  def check_layout(params)
212
- if (params[namespace].nil? || !valid_layout?(params)) && any_required_params?
212
+ value = namespace ? params[namespace] : params
213
+
214
+ if value.nil?
215
+ raise ValidationError.new('invalid input layout', {}) if any_required_params?
216
+
217
+ elsif !valid_layout?(value)
213
218
  raise ValidationError.new('invalid input layout', {})
214
219
  end
215
220
 
221
+ return unless namespace
222
+
216
223
  case layout
217
224
  when :object, :hash
218
225
  params[namespace] ||= {}
@@ -224,12 +231,15 @@ module HaveAPI
224
231
 
225
232
  # Third step of validation. Check if all required params are present,
226
233
  # convert params to correct data types, set default values if necessary.
227
- def validate(params)
234
+ def validate(params, context: nil, only: nil)
228
235
  errors = {}
236
+ permitted = only && only.map(&:to_sym)
229
237
 
230
238
  layout_aware(params) do |input|
231
239
  # First run - coerce values to correct types
232
240
  @params.each do |p|
241
+ next if permitted && !permitted.include?(p.name)
242
+
233
243
  if p.required? && input[p.name].nil?
234
244
  errors[p.name] = ['required parameter missing']
235
245
  next
@@ -241,7 +251,11 @@ module HaveAPI
241
251
  end
242
252
 
243
253
  begin
244
- cleaned = p.clean(input[p.name])
254
+ cleaned = if p.method(:clean).arity.abs > 1
255
+ p.clean(input[p.name], context)
256
+ else
257
+ p.clean(input[p.name])
258
+ end
245
259
  rescue ValidationError => e
246
260
  errors[p.name] ||= []
247
261
  errors[p.name] << e.message
@@ -253,6 +267,7 @@ module HaveAPI
253
267
 
254
268
  # Second run - validate parameters
255
269
  @params.each do |p|
270
+ next if permitted && !permitted.include?(p.name)
256
271
  next if errors.has_key?(p.name)
257
272
  next if input[p.name].nil?
258
273
 
@@ -305,13 +320,17 @@ module HaveAPI
305
320
  kwargs
306
321
  end
307
322
 
308
- def valid_layout?(params)
323
+ def normalize_names(names)
324
+ names.map { |v| v.is_a?(String) ? v.to_sym : v }
325
+ end
326
+
327
+ def valid_layout?(value)
309
328
  case layout
310
329
  when :object, :hash
311
- params[namespace].is_a?(Hash)
330
+ value.is_a?(Hash)
312
331
 
313
332
  when :object_list, :hash_list
314
- params[namespace].is_a?(Array)
333
+ value.is_a?(Array) && value.all?(Hash)
315
334
 
316
335
  else
317
336
  false
@@ -98,7 +98,10 @@ module HaveAPI
98
98
  end
99
99
 
100
100
  hash[:resources].each do |resource, children|
101
- ret[:resources][resource.resource_name.underscore] = resource.describe(children, context)
101
+ child = resource.describe(children, context)
102
+ next if child[:actions].empty? && child[:resources].empty?
103
+
104
+ ret[:resources][resource.resource_name.underscore] = child
102
105
  end
103
106
 
104
107
  context.resource_path = orig_resource_path
@@ -77,18 +77,22 @@ module HaveAPI::Resources
77
77
  class Poll < HaveAPI::Action
78
78
  include Mixin
79
79
 
80
+ MAX_TIMEOUT = 30
81
+
80
82
  desc 'Returns when the action is completed or timeout occurs'
81
83
  http_method :get
82
84
  route '{%{resource}_id}/poll'
83
85
 
84
86
  input(:hash) do
85
- float :timeout, label: 'Timeout', desc: 'in seconds', default: 15, fill: true
87
+ float :timeout, label: 'Timeout', desc: 'in seconds', default: 15, fill: true,
88
+ number: { min: 0, max: MAX_TIMEOUT }
86
89
  float :update_in, label: 'Progress',
87
90
  desc: 'number of seconds after which the state is returned if the progress ' \
88
- 'has changed'
89
- bool :status, desc: 'status to check with if update_in is set'
90
- integer :current, desc: 'progress to check with if update_in is set'
91
- integer :total, desc: 'progress to check with if update_in is set'
91
+ 'has changed',
92
+ nullable: true
93
+ bool :status, desc: 'status to check with if update_in is set', nullable: true
94
+ integer :current, desc: 'progress to check with if update_in is set', nullable: true
95
+ integer :total, desc: 'progress to check with if update_in is set', nullable: true
92
96
  end
93
97
 
94
98
  output(:hash) do
@@ -98,6 +102,10 @@ module HaveAPI::Resources
98
102
  authorize { allow }
99
103
 
100
104
  def exec
105
+ if input[:timeout] > MAX_TIMEOUT
106
+ error!("timeout has to be maximally #{MAX_TIMEOUT}", {}, http_status: 400)
107
+ end
108
+
101
109
  t = Time.now
102
110
 
103
111
  loop do
data/lib/haveapi/route.rb CHANGED
@@ -3,8 +3,8 @@ module HaveAPI
3
3
  attr_reader :path, :sinatra_path, :action, :resource_path
4
4
 
5
5
  def initialize(path, action, resource_path)
6
- @path = path
7
- @sinatra_path = path.gsub(/:([a-zA-Z\-_]+)/, '{\1}')
6
+ @sinatra_path = path.gsub(/:([a-zA-Z0-9\-_]+)/, '{\1}')
7
+ @path = @sinatra_path
8
8
  @action = action
9
9
  @resource_path = resource_path
10
10
  end