haveapi 0.26.5 → 0.27.1

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 (49) 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 +66 -4
  10. data/lib/haveapi/parameters/resource.rb +8 -1
  11. data/lib/haveapi/parameters/typed.rb +94 -10
  12. data/lib/haveapi/params.rb +6 -1
  13. data/lib/haveapi/resource.rb +1 -1
  14. data/lib/haveapi/server.rb +10 -1
  15. data/lib/haveapi/spec/api_builder.rb +8 -3
  16. data/lib/haveapi/spec/spec_methods.rb +20 -10
  17. data/lib/haveapi/version.rb +1 -1
  18. data/spec/action/authorize_spec.rb +317 -0
  19. data/spec/action/dsl_spec.rb +98 -100
  20. data/spec/action/runtime_spec.rb +207 -0
  21. data/spec/action_state_spec.rb +301 -0
  22. data/spec/authentication/basic_spec.rb +108 -0
  23. data/spec/authentication/oauth2_spec.rb +127 -0
  24. data/spec/authentication/token_spec.rb +233 -0
  25. data/spec/authorization_spec.rb +23 -18
  26. data/spec/common_spec.rb +19 -17
  27. data/spec/documentation/auth_filtering_spec.rb +111 -0
  28. data/spec/documentation_spec.rb +165 -2
  29. data/spec/envelope_spec.rb +5 -9
  30. data/spec/extensions/action_exceptions_spec.rb +163 -0
  31. data/spec/hooks_spec.rb +32 -38
  32. data/spec/model_adapters/active_record_spec.rb +413 -0
  33. data/spec/parameters/typed_spec.rb +54 -1
  34. data/spec/params_spec.rb +27 -25
  35. data/spec/resource_spec.rb +36 -22
  36. data/spec/server/integration_spec.rb +71 -0
  37. data/spec/spec_helper.rb +2 -2
  38. data/spec/validators/acceptance_spec.rb +10 -12
  39. data/spec/validators/confirmation_spec.rb +14 -16
  40. data/spec/validators/custom_spec.rb +1 -1
  41. data/spec/validators/exclusion_spec.rb +13 -15
  42. data/spec/validators/format_spec.rb +20 -22
  43. data/spec/validators/inclusion_spec.rb +13 -15
  44. data/spec/validators/length_spec.rb +6 -6
  45. data/spec/validators/numericality_spec.rb +10 -10
  46. data/spec/validators/presence_spec.rb +16 -22
  47. data/test_support/client_test_api.rb +607 -0
  48. data/test_support/client_test_server.rb +59 -0
  49. metadata +16 -3
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe HaveAPI::Action do
4
+ describe 'runtime' do
5
+ api do
6
+ define_resource(:Test) do
7
+ version 1
8
+ auth false
9
+
10
+ define_action(:Echo) do
11
+ http_method :post
12
+ authorize { allow }
13
+
14
+ class << self
15
+ attr_accessor :calls
16
+ end
17
+ self.calls = []
18
+
19
+ input do
20
+ string :msg
21
+ end
22
+
23
+ output do
24
+ string :msg
25
+ end
26
+
27
+ def exec
28
+ self.class.calls << :exec
29
+ { msg: input[:msg] }
30
+ end
31
+ end
32
+
33
+ define_action(:Order) do
34
+ http_method :post
35
+ authorize { allow }
36
+
37
+ class << self
38
+ attr_accessor :calls
39
+ end
40
+ self.calls = []
41
+
42
+ output do
43
+ bool :ok
44
+ end
45
+
46
+ def prepare
47
+ self.class.calls << :prepare
48
+ end
49
+
50
+ def pre_exec
51
+ self.class.calls << :pre_exec
52
+ end
53
+
54
+ def exec
55
+ self.class.calls << :exec
56
+ { ok: true }
57
+ end
58
+ end
59
+
60
+ define_action(:Abort) do
61
+ http_method :post
62
+ authorize { allow }
63
+
64
+ class << self
65
+ attr_accessor :calls
66
+ end
67
+ self.calls = []
68
+
69
+ output do
70
+ bool :ok
71
+ end
72
+
73
+ def exec
74
+ self.class.calls << :exec
75
+ error!('nope')
76
+ self.class.calls << :after_error
77
+ { ok: true }
78
+ end
79
+ end
80
+
81
+ define_action(:Boom) do
82
+ http_method :post
83
+ authorize { allow }
84
+
85
+ class << self
86
+ attr_accessor :calls
87
+ end
88
+ self.calls = []
89
+
90
+ output do
91
+ bool :ok
92
+ end
93
+
94
+ def exec
95
+ self.class.calls << :exec
96
+ raise 'boom'
97
+ end
98
+ end
99
+
100
+ define_action(:Block) do
101
+ http_method :post
102
+ authorize { allow }
103
+ blocking true
104
+
105
+ class << self
106
+ attr_accessor :calls
107
+ end
108
+ self.calls = []
109
+
110
+ output do
111
+ bool :ok
112
+ end
113
+
114
+ def exec
115
+ self.class.calls << :exec
116
+ { ok: true }
117
+ end
118
+
119
+ def state_id
120
+ nil
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ default_version 1
127
+
128
+ def action_class(name)
129
+ action, = find_action(1, :Test, name)
130
+ action
131
+ end
132
+
133
+ def with_exec_exception_hook
134
+ hooks = HaveAPI::Hooks.hooks
135
+ action_hooks = hooks[HaveAPI::Action][:exec_exception]
136
+ original = action_hooks[:listeners].dup
137
+
138
+ HaveAPI::Action.connect_hook(:exec_exception) do |ret, _context, e|
139
+ ret[:status] = false
140
+ ret[:message] = e.message
141
+ ret[:http_status] = 422
142
+ ret
143
+ end
144
+
145
+ yield
146
+ ensure
147
+ action_hooks[:listeners] = original
148
+ end
149
+
150
+ it '_meta.no suppresses metadata' do
151
+ action_class(:echo).calls.clear
152
+
153
+ call_api([:Test], :echo, { test: { msg: 'hi' }, _meta: { no: true } })
154
+
155
+ expect(last_response.status).to eq(200)
156
+ expect(api_response).to be_ok
157
+ expect(api_response.response).to have_key(:test)
158
+ expect(api_response.response).not_to have_key(:_meta)
159
+
160
+ call_api([:Test], :echo, { test: { msg: 'hi' }, _meta: { no: false } })
161
+
162
+ expect(last_response.status).to eq(200)
163
+ expect(api_response).to be_ok
164
+ expect(api_response.response).to have_key(:_meta)
165
+ end
166
+
167
+ it 'runs prepare, pre_exec, exec in order' do
168
+ action_class(:order).calls.clear
169
+
170
+ call_api([:Test], :order, {})
171
+
172
+ expect(api_response).to be_ok
173
+ expect(action_class(:order).calls).to eq(%i[prepare pre_exec exec])
174
+ end
175
+
176
+ it 'aborts execution when error! is called' do
177
+ action_class(:abort).calls.clear
178
+
179
+ call_api([:Test], :abort, {})
180
+
181
+ expect(api_response).not_to be_ok
182
+ expect(api_response.message).to eq('nope')
183
+ expect(action_class(:abort).calls).to include(:exec)
184
+ expect(action_class(:abort).calls).not_to include(:after_error)
185
+ end
186
+
187
+ it 'routes exec exceptions through hook' do
188
+ with_exec_exception_hook do
189
+ call_api([:Test], :boom, {})
190
+
191
+ expect(last_response.status).to eq(422)
192
+ expect(api_response).not_to be_ok
193
+ expect(api_response.message).to eq('boom')
194
+ expect(last_response.body).not_to match(/<html/i)
195
+ end
196
+ end
197
+
198
+ it 'adds action_state_id to meta for blocking actions' do
199
+ call_api([:Test], :block, {})
200
+
201
+ expect(api_response).to be_ok
202
+ meta = api_response.response[:_meta]
203
+ expect(meta).to be_a(Hash)
204
+ expect(meta).to have_key(:action_state_id)
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,301 @@
1
+ require 'time'
2
+
3
+ module ActionStateSpec
4
+ FIXED_TIME = Time.utc(2020, 1, 1, 0, 0, 0)
5
+
6
+ class State
7
+ attr_reader :id, :label, :created_at, :updated_at, :status, :progress, :poll_calls
8
+
9
+ def initialize(id:, label: 'job', status: true, finished: false, can_cancel: false,
10
+ progress: {}, valid: true, cancel_ret: true)
11
+ @id = id
12
+ @label = label
13
+ @status = status
14
+ @finished = finished
15
+ @can_cancel = can_cancel
16
+ @progress = progress
17
+ @valid = valid
18
+ @cancel_ret = cancel_ret
19
+ @poll_calls = 0
20
+ @created_at = FIXED_TIME
21
+ @updated_at = FIXED_TIME
22
+ end
23
+
24
+ def valid?
25
+ @valid
26
+ end
27
+
28
+ def finished?
29
+ @finished
30
+ end
31
+
32
+ def can_cancel?
33
+ @can_cancel
34
+ end
35
+
36
+ def poll(_input)
37
+ @poll_calls += 1
38
+
39
+ if @progress[:current]
40
+ @progress = @progress.merge(current: @progress[:current] + 1)
41
+ end
42
+
43
+ @updated_at = Time.utc(2020, 1, 1, 0, 0, @poll_calls)
44
+ self
45
+ end
46
+
47
+ def cancel
48
+ @cancel_ret
49
+ end
50
+ end
51
+
52
+ module Backend
53
+ class << self
54
+ attr_reader :states, :list_calls, :new_calls
55
+
56
+ def reset!
57
+ @states = {}
58
+ @list_calls = []
59
+ @new_calls = []
60
+ end
61
+
62
+ def add_state(state)
63
+ @states[state.id] = state
64
+ end
65
+
66
+ def list_pending(user, from_id, limit, order)
67
+ @list_calls << {
68
+ user: user,
69
+ from_id: from_id,
70
+ limit: limit,
71
+ order: order
72
+ }
73
+ @states.values
74
+ end
75
+
76
+ def new(user, id:)
77
+ @new_calls << { user: user, id: id.to_i }
78
+ @states[id.to_i] || State.new(id: id.to_i, valid: false)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ describe HaveAPI::Resources::ActionState do
85
+ def get_action(path, params = nil)
86
+ if params
87
+ get path, params, input: ''
88
+ else
89
+ get path, {}, input: ''
90
+ end
91
+ end
92
+
93
+ context 'without action_state backend' do
94
+ empty_api
95
+ use_version 1
96
+ default_version 1
97
+
98
+ it 'is not mounted without action_state backend' do
99
+ header 'Accept', 'application/json'
100
+ get_action '/v1/action_states'
101
+
102
+ expect(last_response.status).to eq(404)
103
+ expect(api_response).not_to be_ok
104
+ expect(api_response.message).to eq('Action not found')
105
+ end
106
+ end
107
+
108
+ context 'with action_state backend' do
109
+ empty_api
110
+ use_version 1
111
+ default_version 1
112
+ action_state ActionStateSpec::Backend
113
+
114
+ before do
115
+ ActionStateSpec::Backend.reset!
116
+ header 'Accept', 'application/json'
117
+ end
118
+
119
+ it 'lists pending states and passes paging options' do
120
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
121
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 2))
122
+
123
+ get_action '/v1/action_states', action_state: { from_id: 10, limit: 2, order: 'oldest' }
124
+
125
+ expect(last_response.status).to eq(200)
126
+ expect(api_response).to be_ok
127
+
128
+ states = api_response[:action_states]
129
+ expect(states).to be_a(Array)
130
+ expect(states.size).to eq(2)
131
+
132
+ states.each do |state|
133
+ expect(state.keys).to contain_exactly(
134
+ :id, :label, :status, :finished, :current, :total, :unit,
135
+ :can_cancel, :created_at, :updated_at
136
+ )
137
+ expect(state[:created_at]).to eq(ActionStateSpec::FIXED_TIME.iso8601)
138
+ expect(state[:updated_at]).to eq(ActionStateSpec::FIXED_TIME.iso8601)
139
+ end
140
+
141
+ call = ActionStateSpec::Backend.list_calls.last
142
+ expect(call[:user]).to be_nil
143
+ expect(call[:from_id]).to eq(10)
144
+ expect(call[:limit]).to eq(2)
145
+ expect(call[:order]).to eq(:oldest)
146
+ end
147
+
148
+ it 'defaults order to newest' do
149
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
150
+
151
+ get_action '/v1/action_states'
152
+
153
+ expect(ActionStateSpec::Backend.list_calls.last[:order]).to eq(:newest)
154
+ end
155
+
156
+ it 'shows state when valid' do
157
+ ActionStateSpec::Backend.add_state(
158
+ ActionStateSpec::State.new(id: 1, progress: {}, can_cancel: true)
159
+ )
160
+
161
+ get_action '/v1/action_states/1'
162
+
163
+ expect(api_response).to be_ok
164
+ state = api_response[:action_state]
165
+ expect(state[:id]).to eq(1)
166
+ expect(state[:current]).to eq(0)
167
+ expect(state[:total]).to eq(0)
168
+ expect(state[:unit]).to be_nil
169
+ expect(state[:can_cancel]).to be(true)
170
+ end
171
+
172
+ it 'returns error when state invalid' do
173
+ get_action '/v1/action_states/999'
174
+
175
+ expect(api_response).not_to be_ok
176
+ expect(api_response.message).to eq('action state not found')
177
+ end
178
+
179
+ it 'poll returns immediately if finished' do
180
+ state = ActionStateSpec::State.new(
181
+ id: 1,
182
+ finished: true,
183
+ progress: { current: 5, total: 10, unit: 'items' }
184
+ )
185
+ ActionStateSpec::Backend.add_state(state)
186
+
187
+ get_action '/v1/action_states/1/poll', action_state: { timeout: 5 }
188
+
189
+ expect(api_response).to be_ok
190
+ ret = api_response[:action_state]
191
+ expect(ret[:finished]).to be(true)
192
+ expect(ret[:current]).to eq(5)
193
+ expect(ret[:total]).to eq(10)
194
+ expect(ret[:unit]).to eq('items')
195
+ expect(state.poll_calls).to eq(0)
196
+ end
197
+
198
+ it 'poll returns immediately on timeout without polling' do
199
+ state = ActionStateSpec::State.new(
200
+ id: 1,
201
+ finished: false,
202
+ progress: { current: 0, total: 10 }
203
+ )
204
+ ActionStateSpec::Backend.add_state(state)
205
+
206
+ get_action '/v1/action_states/1/poll', action_state: { timeout: 0 }
207
+
208
+ expect(api_response).to be_ok
209
+ expect(state.poll_calls).to eq(0)
210
+ end
211
+
212
+ it 'poll returns immediately when update_in check mismatches' do
213
+ state = ActionStateSpec::State.new(
214
+ id: 1,
215
+ status: true,
216
+ progress: { current: 1, total: 10 }
217
+ )
218
+ ActionStateSpec::Backend.add_state(state)
219
+
220
+ get_action '/v1/action_states/1/poll', action_state: {
221
+ timeout: 10,
222
+ update_in: 5,
223
+ status: false,
224
+ current: 999,
225
+ total: 10
226
+ }
227
+
228
+ expect(api_response).to be_ok
229
+ ret = api_response[:action_state]
230
+ expect(ret[:status]).to be(true)
231
+ expect(ret[:current]).to eq(1)
232
+ expect(ret[:total]).to eq(10)
233
+ expect(state.poll_calls).to eq(0)
234
+ end
235
+
236
+ it 'poll uses state.poll when available' do
237
+ state = ActionStateSpec::State.new(
238
+ id: 1,
239
+ finished: false,
240
+ progress: { current: 0, total: 10 }
241
+ )
242
+ ActionStateSpec::Backend.add_state(state)
243
+
244
+ get_action '/v1/action_states/1/poll', action_state: { timeout: 10 }
245
+
246
+ expect(api_response).to be_ok
247
+ ret = api_response[:action_state]
248
+ expect(state.poll_calls).to eq(1)
249
+ expect(ret[:current]).to eq(1)
250
+ end
251
+
252
+ it 'poll returns error when state invalid' do
253
+ get_action '/v1/action_states/999/poll', action_state: { timeout: 0 }
254
+
255
+ expect(api_response).not_to be_ok
256
+ expect(api_response.message).to eq('action state not found')
257
+ end
258
+
259
+ it 'cancel returns ok for true' do
260
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1, cancel_ret: true))
261
+
262
+ call_api(:post, '/v1/action_states/1/cancel', {})
263
+
264
+ expect(api_response).to be_ok
265
+ expect(api_response.response[:_meta]).to be_a(Hash)
266
+ expect(api_response.response[:_meta]).to have_key(:action_state_id)
267
+ expect(api_response[:action_state]).to eq({})
268
+ end
269
+
270
+ it 'cancel returns action_state_id for numeric return' do
271
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1, cancel_ret: 123))
272
+
273
+ call_api(:post, '/v1/action_states/1/cancel', {})
274
+
275
+ expect(api_response).to be_ok
276
+ expect(api_response.response[:_meta][:action_state_id]).to eq(123)
277
+ end
278
+
279
+ it 'cancel returns error for false' do
280
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1, cancel_ret: false))
281
+
282
+ call_api(:post, '/v1/action_states/1/cancel', {})
283
+
284
+ expect(api_response).not_to be_ok
285
+ expect(api_response.message).to eq('cancellation failed')
286
+ end
287
+
288
+ it 'cancel returns error for NotImplementedError' do
289
+ state = ActionStateSpec::State.new(id: 1)
290
+ def state.cancel
291
+ raise NotImplementedError, 'not supported'
292
+ end
293
+ ActionStateSpec::Backend.add_state(state)
294
+
295
+ call_api(:post, '/v1/action_states/1/cancel', {})
296
+
297
+ expect(api_response).not_to be_ok
298
+ expect(api_response.message).to eq('not supported')
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ module AuthSpecBasic
6
+ User = Struct.new(:id, :login)
7
+
8
+ class Provider < HaveAPI::Authentication::Basic::Provider
9
+ protected
10
+
11
+ def find_user(_request, username, password)
12
+ return User.new(1, username) if username == 'user' && password == 'pass'
13
+ return nil unless username == 'error'
14
+
15
+ # Exercise the rescue path in Basic::Provider#authenticate
16
+ raise HaveAPI::AuthenticationError, 'backend failed'
17
+ end
18
+ end
19
+ end
20
+
21
+ describe HaveAPI::Authentication::Basic::Provider do
22
+ api do
23
+ define_resource(:Secure) do
24
+ version 1
25
+ desc 'Secured resource'
26
+
27
+ define_action(:Ping) do
28
+ route 'ping'
29
+ http_method :post
30
+ auth true
31
+
32
+ input(:hash) do
33
+ # accept empty JSON body
34
+ end
35
+
36
+ output(:hash) do
37
+ integer :user_id
38
+ string :login
39
+ end
40
+
41
+ authorize { allow }
42
+
43
+ def exec
44
+ {
45
+ user_id: current_user.id,
46
+ login: current_user.login
47
+ }
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ default_version 1
54
+ auth_chain AuthSpecBasic::Provider
55
+
56
+ let(:seen_users) { [] }
57
+ let(:api_instance) do
58
+ app
59
+ instance_variable_get(:@api)
60
+ end
61
+
62
+ before do
63
+ api_instance.connect_hook(:post_authenticated) do |ret, current_user|
64
+ seen_users << current_user
65
+ ret
66
+ end
67
+ end
68
+
69
+ it 'returns 401 without credentials' do
70
+ call_api(:post, '/v1/secures/ping', {})
71
+
72
+ expect(last_response.status).to eq(401)
73
+ expect(last_response.headers['www-authenticate']).to include('Basic realm=')
74
+
75
+ expect(api_response).to be_failed
76
+ expect(api_response.message).to include('authenticate')
77
+ expect(seen_users.last).to be_nil
78
+ end
79
+
80
+ it 'returns 401 with wrong credentials' do
81
+ login('user', 'wrong')
82
+ call_api(:post, '/v1/secures/ping', {})
83
+
84
+ expect(last_response.status).to eq(401)
85
+ expect(api_response).to be_failed
86
+ expect(seen_users.last).to be_nil
87
+ end
88
+
89
+ it 'authenticates with correct credentials' do
90
+ login('user', 'pass')
91
+ call_api(:post, '/v1/secures/ping', {})
92
+
93
+ expect(last_response.status).to eq(200)
94
+ expect(api_response).to be_ok
95
+ expect(api_response[:secure][:user_id]).to eq(1)
96
+ expect(api_response[:secure][:login]).to eq('user')
97
+ expect(seen_users.last).to be_a(AuthSpecBasic::User)
98
+ end
99
+
100
+ it 'handles AuthenticationError raised by backend' do
101
+ login('error', 'pass')
102
+ call_api(:post, '/v1/secures/ping', {})
103
+
104
+ expect(last_response.status).to eq(401)
105
+ expect(api_response).to be_failed
106
+ expect(seen_users.last).to be_nil
107
+ end
108
+ end