haveapi 0.26.5 → 0.27.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.rspec +1 -0
  3. data/Gemfile +7 -3
  4. data/haveapi.gemspec +1 -1
  5. data/lib/haveapi/action.rb +1 -1
  6. data/lib/haveapi/authentication/base.rb +2 -0
  7. data/lib/haveapi/authentication/oauth2/provider.rb +10 -2
  8. data/lib/haveapi/authentication/token/provider.rb +25 -1
  9. data/lib/haveapi/model_adapters/active_record.rb +61 -4
  10. data/lib/haveapi/parameters/typed.rb +94 -10
  11. data/lib/haveapi/params.rb +6 -1
  12. data/lib/haveapi/resource.rb +1 -1
  13. data/lib/haveapi/server.rb +10 -1
  14. data/lib/haveapi/spec/api_builder.rb +8 -3
  15. data/lib/haveapi/spec/spec_methods.rb +20 -10
  16. data/lib/haveapi/version.rb +1 -1
  17. data/spec/action/authorize_spec.rb +317 -0
  18. data/spec/action/dsl_spec.rb +98 -100
  19. data/spec/action/runtime_spec.rb +207 -0
  20. data/spec/action_state_spec.rb +301 -0
  21. data/spec/authentication/basic_spec.rb +108 -0
  22. data/spec/authentication/oauth2_spec.rb +127 -0
  23. data/spec/authentication/token_spec.rb +233 -0
  24. data/spec/authorization_spec.rb +23 -18
  25. data/spec/common_spec.rb +19 -17
  26. data/spec/documentation/auth_filtering_spec.rb +111 -0
  27. data/spec/documentation_spec.rb +165 -2
  28. data/spec/envelope_spec.rb +5 -9
  29. data/spec/extensions/action_exceptions_spec.rb +163 -0
  30. data/spec/hooks_spec.rb +32 -38
  31. data/spec/model_adapters/active_record_spec.rb +411 -0
  32. data/spec/parameters/typed_spec.rb +54 -1
  33. data/spec/params_spec.rb +27 -25
  34. data/spec/resource_spec.rb +36 -22
  35. data/spec/server/integration_spec.rb +71 -0
  36. data/spec/spec_helper.rb +2 -2
  37. data/spec/validators/acceptance_spec.rb +10 -12
  38. data/spec/validators/confirmation_spec.rb +14 -16
  39. data/spec/validators/custom_spec.rb +1 -1
  40. data/spec/validators/exclusion_spec.rb +13 -15
  41. data/spec/validators/format_spec.rb +20 -22
  42. data/spec/validators/inclusion_spec.rb +13 -15
  43. data/spec/validators/length_spec.rb +6 -6
  44. data/spec/validators/numericality_spec.rb +10 -10
  45. data/spec/validators/presence_spec.rb +16 -22
  46. data/test_support/client_test_api.rb +583 -0
  47. data/test_support/client_test_server.rb +59 -0
  48. metadata +16 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4c9848a4bb22d9242a42df08d018a4c504e242609e999c7225154b9dd36c559a
4
- data.tar.gz: be4a7ba2e9a17314f1d778650d9ff1a0c4dab3151526345e6bce55593c1eb997
3
+ metadata.gz: 8307e5e40983cf927641fd3d4052058406209093520a83d89121883f64a9ba0e
4
+ data.tar.gz: b2d80c7621b7f8870e89dff0ae78c3f4f2d5587ddc9151733d795df0c279fbb7
5
5
  SHA512:
6
- metadata.gz: 4cac3416fb41675be762aec72ec252b48d31ff34e76f05f603d8871973c901ffc57d003dd99b552065621044f8730918cb908f31a591eeb8bfb8b852cd426307
7
- data.tar.gz: be0b3d8070d91c76cd870beae17cb046c8d9137c23c20eb75747849729974ab7c0d3a9a8180b50a6d8cbeacddd06c9df197d0734f33afa1696575b93ee2184ec
6
+ metadata.gz: b93b9411fb84cf5e67763e9384746da97f32903126e802ca2a47f545f68c9d37f66c54c280e78bc7b25e9abad19ca99e3a6d9ed68a985daa8c053119d459ed6d
7
+ data.tar.gz: 06fff468dc3e939c838689fbd515ae280cf590c9a05d1c36dc81ba7c2723ab344acadee67ece39fb782ff10c75baf7be906fa2e786e5f7cf9a9ae47fe110d9f1
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile CHANGED
@@ -5,10 +5,14 @@ gem 'haveapi-client', path: '../../clients/ruby'
5
5
 
6
6
  group :test do
7
7
  gem 'rack-test'
8
+ gem 'rackup'
8
9
  gem 'rspec'
9
- end
10
+ gem 'rubocop', require: false
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'webrick'
10
13
 
11
- group :activerecord do
12
- gem 'activerecord', '>= 6.0'
14
+ # ActiveRecord adapter specs (always run)
15
+ gem 'activerecord', '>= 8.0'
13
16
  gem 'sinatra-activerecord'
17
+ gem 'sqlite3'
14
18
  end
data/haveapi.gemspec CHANGED
@@ -15,7 +15,7 @@ Gem::Specification.new do |s|
15
15
  s.required_ruby_version = ">= #{File.read('../../.ruby-version').strip}"
16
16
 
17
17
  s.add_dependency 'activesupport', '>= 7.1'
18
- s.add_dependency 'haveapi-client', '~> 0.26.5'
18
+ s.add_dependency 'haveapi-client', '~> 0.27.0'
19
19
  s.add_dependency 'json'
20
20
  s.add_dependency 'mail'
21
21
  s.add_dependency 'nesty', '~> 1.0'
@@ -372,7 +372,7 @@ module HaveAPI
372
372
  if tmp.empty?
373
373
  p e.message
374
374
  puts e.backtrace
375
- error!('Server error occurred')
375
+ error!('Server error occurred', {}, http_status: 500)
376
376
  end
377
377
 
378
378
  unless tmp[:status]
@@ -1,5 +1,7 @@
1
1
  module HaveAPI
2
2
  module Authentication
3
+ class TokenConflict < StandardError; end
4
+
3
5
  # Base class for authentication providers.
4
6
  class Base
5
7
  # Get or set auth method name
@@ -117,7 +117,7 @@ module HaveAPI::Authentication
117
117
  request.params['access_token'],
118
118
  token_from_authorization_header(request),
119
119
  token_from_haveapi_header(request)
120
- ].compact
120
+ ].select { |value| token_present?(value) }
121
121
 
122
122
  token =
123
123
  case tokens.length
@@ -126,7 +126,8 @@ module HaveAPI::Authentication
126
126
  when 1
127
127
  tokens.first
128
128
  else
129
- raise 'Too many oauth2 tokens'
129
+ raise HaveAPI::Authentication::TokenConflict,
130
+ 'Multiple OAuth2 tokens provided'
130
131
  end
131
132
 
132
133
  token && config.find_user_by_access_token(request, token)
@@ -310,6 +311,13 @@ module HaveAPI::Authentication
310
311
  def header_to_env(header)
311
312
  "HTTP_#{header.upcase.gsub('-', '_')}"
312
313
  end
314
+
315
+ def token_present?(value)
316
+ return false if value.nil?
317
+ return false if value.respond_to?(:empty?) && value.empty?
318
+
319
+ true
320
+ end
313
321
  end
314
322
  end
315
323
  end
@@ -159,7 +159,24 @@ module HaveAPI::Authentication
159
159
  # @param request [Sinatra::Request]
160
160
  # @return [String]
161
161
  def token(request)
162
- request.params[config.class.query_parameter] || request.env[header_to_env]
162
+ param = config.class.query_parameter
163
+ query_token = [
164
+ request.params[param],
165
+ request.params[param.to_s]
166
+ ].find { |value| token_present?(value) }
167
+ header_token = request.env[header_to_env]
168
+
169
+ tokens = [query_token, header_token].select { |value| token_present?(value) }
170
+
171
+ case tokens.length
172
+ when 0
173
+ nil
174
+ when 1
175
+ tokens.first
176
+ else
177
+ raise HaveAPI::Authentication::TokenConflict,
178
+ 'Multiple authentication tokens provided'
179
+ end
163
180
  end
164
181
 
165
182
  def describe
@@ -176,6 +193,13 @@ module HaveAPI::Authentication
176
193
  "HTTP_#{config.class.http_header.upcase.gsub('-', '_')}"
177
194
  end
178
195
 
196
+ def token_present?(value)
197
+ return false if value.nil?
198
+ return false if value.respond_to?(:empty?) && value.empty?
199
+
200
+ true
201
+ end
202
+
179
203
  def token_resource
180
204
  provider = self
181
205
 
@@ -142,12 +142,69 @@ module HaveAPI::ModelAdapters
142
142
 
143
143
  class Input < ::HaveAPI::ModelAdapter::Input
144
144
  def self.clean(model, raw, extra)
145
- return if (raw.is_a?(String) && raw.empty?) || (!raw.is_a?(String) && !raw)
145
+ return nil if raw.nil?
146
+
147
+ original = raw
148
+
149
+ if raw.is_a?(String)
150
+ stripped = raw.strip
151
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}" if stripped.empty?
152
+
153
+ raw = stripped
154
+
155
+ elsif [true, false].include?(raw)
156
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}"
157
+ end
158
+
159
+ value = if integer_pk?(model)
160
+ id = coerce_integer_id(raw, original)
161
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}" if id < 0
162
+
163
+ id
164
+ else
165
+ raw
166
+ end
146
167
 
147
168
  if extra[:fetch]
148
- model.instance_exec(raw, &extra[:fetch])
169
+ model.instance_exec(value, &extra[:fetch])
170
+ else
171
+ model.find(value)
172
+ end
173
+ rescue ::ActiveRecord::RecordNotFound
174
+ raise HaveAPI::ValidationError, 'resource not found'
175
+ rescue ArgumentError, TypeError
176
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}"
177
+ end
178
+
179
+ def self.integer_pk?(model)
180
+ pk = model.primary_key
181
+ return false unless pk.is_a?(String)
182
+ return false unless model.respond_to?(:type_for_attribute)
183
+
184
+ pk_type = model.type_for_attribute(pk).type
185
+ %i[integer bigint].include?(pk_type)
186
+ end
187
+
188
+ def self.coerce_integer_id(raw, original)
189
+ case raw
190
+ when Integer
191
+ raw
192
+ when Float
193
+ unless raw.finite? && (raw % 1) == 0
194
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}"
195
+ end
196
+
197
+ raw.to_i
198
+ when String
199
+ s = raw.strip
200
+
201
+ if s.empty? || !s.match?(/\A[+-]?\d+\z/)
202
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}"
203
+ end
204
+
205
+ Integer(s, 10)
149
206
  else
150
- model.find(raw)
207
+ raise HaveAPI::ValidationError, "not a valid id #{original.inspect}"
151
208
  end
152
209
  end
153
210
  end
@@ -397,7 +454,7 @@ module HaveAPI::ModelAdapters
397
454
  opts[:min] = v.options[:greater_than_or_equal_to] if v.options[:greater_than_or_equal_to]
398
455
 
399
456
  if v.options[:equal_to]
400
- validator(accept: v.options[:equal_to])
457
+ validator(:accept, v.options[:equal_to])
401
458
  next
402
459
  end
403
460
 
@@ -33,7 +33,7 @@ module HaveAPI::Parameters
33
33
  end
34
34
 
35
35
  def optional?
36
- !@required
36
+ !required?
37
37
  end
38
38
 
39
39
  def fill?
@@ -82,20 +82,19 @@ module HaveAPI::Parameters
82
82
  nil
83
83
 
84
84
  elsif @type == Integer
85
- raw.to_i
85
+ coerce_integer(raw)
86
86
 
87
87
  elsif @type == Float
88
- raw.to_f
88
+ coerce_float(raw)
89
89
 
90
90
  elsif @type == Boolean
91
- Boolean.to_b(raw)
91
+ coerce_boolean(raw)
92
92
 
93
93
  elsif @type == ::Datetime
94
- begin
95
- DateTime.iso8601(raw).to_time
96
- rescue ArgumentError
97
- raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
98
- end
94
+ coerce_datetime(raw)
95
+
96
+ elsif @type == String || @type == Text
97
+ coerce_string(raw)
99
98
 
100
99
  else
101
100
  raw
@@ -122,12 +121,97 @@ module HaveAPI::Parameters
122
121
  elsif @type == Float
123
122
  v.to_f
124
123
 
125
- elsif @type == String
124
+ elsif @type == String || @type == Text
126
125
  v.to_s
127
126
 
128
127
  else
129
128
  v
130
129
  end
131
130
  end
131
+
132
+ private
133
+
134
+ def coerce_integer(raw)
135
+ case raw
136
+ when Integer
137
+ raw
138
+ when Float
139
+ unless raw.finite? && (raw % 1) == 0
140
+ raise HaveAPI::ValidationError, "not a valid integer #{raw.inspect}"
141
+ end
142
+
143
+ raw.to_i
144
+ when String
145
+ s = raw.strip
146
+
147
+ if s.empty? || !s.match?(/\A[+-]?\d+\z/)
148
+ raise HaveAPI::ValidationError, "not a valid integer #{raw.inspect}"
149
+ end
150
+
151
+ Integer(s, 10)
152
+ else
153
+ raise HaveAPI::ValidationError, "not a valid integer #{raw.inspect}"
154
+ end
155
+ end
156
+
157
+ def coerce_float(raw)
158
+ if raw.is_a?(Numeric)
159
+ f = raw.to_f
160
+
161
+ elsif raw.is_a?(String)
162
+ s = raw.strip
163
+ raise HaveAPI::ValidationError, "not a valid float #{raw.inspect}" if s.empty?
164
+
165
+ begin
166
+ f = Float(s)
167
+ rescue ArgumentError
168
+ raise HaveAPI::ValidationError, "not a valid float #{raw.inspect}"
169
+ end
170
+
171
+ else
172
+ raise HaveAPI::ValidationError, "not a valid float #{raw.inspect}"
173
+ end
174
+
175
+ raise HaveAPI::ValidationError, "not a valid float #{raw.inspect}" unless f.finite?
176
+
177
+ f
178
+ end
179
+
180
+ def coerce_boolean(raw)
181
+ return true if raw == true
182
+ return false if raw == false
183
+
184
+ if raw.is_a?(Integer)
185
+ return false if raw == 0
186
+ return true if raw == 1
187
+
188
+ elsif raw.is_a?(String)
189
+ s = raw.strip
190
+ raise HaveAPI::ValidationError, "not a valid boolean #{raw.inspect}" if s.empty?
191
+
192
+ return true if %w[true t yes y 1].include?(s.downcase)
193
+ return false if %w[false f no n 0].include?(s.downcase)
194
+ end
195
+
196
+ raise HaveAPI::ValidationError, "not a valid boolean #{raw.inspect}"
197
+ end
198
+
199
+ def coerce_datetime(raw)
200
+ if raw.is_a?(String) && raw.strip.empty?
201
+ raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
202
+ end
203
+
204
+ DateTime.iso8601(raw).to_time
205
+ rescue ArgumentError
206
+ raise HaveAPI::ValidationError, "not in ISO 8601 format '#{raw}'"
207
+ end
208
+
209
+ def coerce_string(raw)
210
+ if raw.is_a?(Array) || raw.is_a?(Hash)
211
+ raise HaveAPI::ValidationError, "not a valid string #{raw.inspect}"
212
+ end
213
+
214
+ raw.to_s
215
+ end
132
216
  end
133
217
  end
@@ -64,7 +64,12 @@ module HaveAPI
64
64
  return @cache[:namespace] = @namespace unless @namespace.nil?
65
65
 
66
66
  n = @action.resource.resource_name.underscore
67
- n = n.pluralize if %i[object_list hash_list].include?(layout)
67
+ n = if %i[object_list hash_list].include?(layout)
68
+ n.pluralize
69
+ else
70
+ n.singularize
71
+ end
72
+
68
73
  @cache[:namespace] = n.to_sym
69
74
  end
70
75
 
@@ -116,7 +116,7 @@ module HaveAPI
116
116
  cls
117
117
  end
118
118
 
119
- def self.define_action(name, superclass: Action, &)
119
+ def self.define_action(name, superclass: HaveAPI::Action, &)
120
120
  return false if const_defined?(name)
121
121
 
122
122
  cls = Class.new(superclass)
@@ -65,7 +65,16 @@ module HaveAPI
65
65
  def authenticated?(v)
66
66
  return @current_user if @current_user
67
67
 
68
- @current_user = settings.api_server.send(:do_authenticate, v, request)
68
+ begin
69
+ @current_user = settings.api_server.send(:do_authenticate, v, request)
70
+ rescue HaveAPI::Authentication::TokenConflict => e
71
+ unless @formatter
72
+ @formatter = OutputFormatter.new
73
+ @formatter.supports?([])
74
+ end
75
+
76
+ report_error(400, {}, e.message)
77
+ end
69
78
  settings.api_server.call_hooks_for(:post_authenticated, args: [@current_user])
70
79
  @current_user
71
80
  end
@@ -14,7 +14,7 @@ module HaveAPI::Spec
14
14
  def api(mod = nil, &block)
15
15
  unless mod
16
16
  mod = Module.new do
17
- def self.define_resource(name, superclass: Resource, &block)
17
+ def self.define_resource(name, superclass: HaveAPI::Resource, &block)
18
18
  return false if const_defined?(name)
19
19
 
20
20
  cls = Class.new(superclass)
@@ -39,7 +39,7 @@ module HaveAPI::Spec
39
39
 
40
40
  # Select API versions to be used.
41
41
  def use_version(v)
42
- before(:each) do
42
+ before do
43
43
  self.class.opt(:versions, v)
44
44
  end
45
45
  end
@@ -49,6 +49,11 @@ module HaveAPI::Spec
49
49
  opt(:default_version, v)
50
50
  end
51
51
 
52
+ # Set action state backend to mount HaveAPI::Resources::ActionState
53
+ def action_state(backend)
54
+ opt(:action_state, backend)
55
+ end
56
+
52
57
  # Set a custom mount path.
53
58
  def mount_to(path)
54
59
  opt(:mount, path)
@@ -56,7 +61,7 @@ module HaveAPI::Spec
56
61
 
57
62
  # Login using HTTP basic.
58
63
  def login(*credentials)
59
- before(:each) do
64
+ before do
60
65
  basic_authorize(*credentials)
61
66
  end
62
67
  end
@@ -13,6 +13,8 @@ module HaveAPI::Spec
13
13
  @api.auth_chain << auth if auth
14
14
  @api.use_version(get_opt(:versions) || :all)
15
15
  @api.default_version = default if default
16
+ as = get_opt(:action_state)
17
+ @api.action_state = as if as
16
18
  @api.mount(get_opt(:mount) || '/')
17
19
  @api.app
18
20
  end
@@ -39,20 +41,28 @@ module HaveAPI::Spec
39
41
  r_name, a_name
40
42
  )
41
43
 
42
- method(action.http_method).call(
43
- path,
44
- params && params.to_json,
45
- { 'content-type' => 'application/json' }
46
- )
44
+ json_body = params && params.to_json
45
+ env = { 'CONTENT_TYPE' => 'application/json' }
46
+
47
+ if %i[get head options].include?(action.http_method.to_sym)
48
+ method(action.http_method).call(path, params, env)
49
+ else
50
+ env[:input] = json_body if json_body
51
+ method(action.http_method).call(path, nil, env)
52
+ end
47
53
 
48
54
  else
49
55
  http_method, path, params = args
50
56
 
51
- method(http_method).call(
52
- path,
53
- params && params.to_json,
54
- { 'content-type' => 'application/json' }
55
- )
57
+ json_body = params && params.to_json
58
+ env = { 'CONTENT_TYPE' => 'application/json' }
59
+
60
+ if %i[get head options].include?(http_method.to_sym)
61
+ method(http_method).call(path, params, env)
62
+ else
63
+ env[:input] = json_body if json_body
64
+ method(http_method).call(path, nil, env)
65
+ end
56
66
  end
57
67
  end
58
68
 
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.26.5'.freeze
3
+ VERSION = '0.27.0'.freeze
4
4
  end