haveapi 0.28.1 → 0.28.2

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.
@@ -54,6 +54,16 @@ module HaveAPI::Spec
54
54
  opt(:action_state, backend)
55
55
  end
56
56
 
57
+ # Set action state authentication mode.
58
+ def action_state_auth(mode)
59
+ opt(:action_state_auth, mode)
60
+ end
61
+
62
+ # Set HTTP status for action validation errors. Nil preserves legacy 200.
63
+ def validation_error_http_status(status)
64
+ opt(:validation_error_http_status, status)
65
+ end
66
+
57
67
  # Set a custom mount path.
58
68
  def mount_to(path)
59
69
  opt(:mount, path)
@@ -9,15 +9,16 @@ module HaveAPI::Spec
9
9
  end
10
10
 
11
11
  def call(input, user: nil, &)
12
- action = @action.new(nil, @v, input, nil, HaveAPI::Context.new(
13
- @server,
14
- version: @v,
15
- action: @action,
16
- path: @path,
17
- params: input,
18
- user:,
19
- endpoint: true
20
- ))
12
+ context = HaveAPI::Context.new(
13
+ @server,
14
+ version: @v,
15
+ action: @action,
16
+ path: @path,
17
+ input:,
18
+ user:,
19
+ endpoint: true
20
+ )
21
+ action = @action.new(nil, @v, {}, input, context)
21
22
 
22
23
  unless action.authorized?(user)
23
24
  raise 'Access denied. Insufficient permissions.'
@@ -15,6 +15,10 @@ module HaveAPI::Spec
15
15
  @api.default_version = default if default
16
16
  as = get_opt(:action_state)
17
17
  @api.action_state = as if as
18
+ asa = get_opt(:action_state_auth)
19
+ @api.action_state_auth = asa if asa
20
+ ves = get_opt(:validation_error_http_status)
21
+ @api.validation_error_http_status = ves if ves
18
22
  @api.mount(get_opt(:mount) || '/')
19
23
  @api.app
20
24
  end
@@ -1,4 +1,4 @@
1
1
  module HaveAPI
2
2
  PROTOCOL_VERSION = '2.0'.freeze
3
- VERSION = '0.28.1'.freeze
3
+ VERSION = '0.28.2'.freeze
4
4
  end
@@ -130,7 +130,7 @@ describe HaveAPI::Action do
130
130
  end
131
131
 
132
132
  def exec
133
- { value: params['account_id'] }
133
+ { value: path_params['account_id'] }
134
134
  end
135
135
  end
136
136
 
@@ -153,7 +153,7 @@ describe HaveAPI::Action do
153
153
  end
154
154
 
155
155
  def exec
156
- { route_profile_id: params['profile_id'] }
156
+ { route_profile_id: path_params['profile_id'] }
157
157
  end
158
158
  end
159
159
 
@@ -231,6 +231,59 @@ describe HaveAPI::Action do
231
231
  end
232
232
  end
233
233
 
234
+ define_action(:MetadataSpecificOutput) do
235
+ route 'metadata_specific_output'
236
+ http_method :post
237
+
238
+ authorize do
239
+ output whitelist: [:ok]
240
+ meta_output blacklist: [:secret_status]
241
+ allow
242
+ end
243
+
244
+ meta(:global) do
245
+ output do
246
+ string :public_status
247
+ string :secret_status
248
+ end
249
+ end
250
+
251
+ output do
252
+ bool :ok
253
+ string :hidden_body
254
+ end
255
+
256
+ def exec
257
+ set_meta(public_status: 'queued', secret_status: 'internal-token')
258
+ { ok: true, hidden_body: 'body-secret' }
259
+ end
260
+ end
261
+
262
+ define_action(:WhitelistedIndex, superclass: HaveAPI::Actions::Default::Index) do
263
+ route 'whitelisted'
264
+
265
+ authorize do
266
+ output whitelist: [:name]
267
+ allow
268
+ end
269
+
270
+ output(:hash_list) do
271
+ string :name
272
+ string :secret
273
+ end
274
+
275
+ def count
276
+ 2
277
+ end
278
+
279
+ def exec
280
+ [
281
+ { name: 'one', secret: 'hidden' },
282
+ { name: 'two', secret: 'hidden' }
283
+ ]
284
+ end
285
+ end
286
+
234
287
  define_action(:UnnamespacedInput) do
235
288
  route 'unnamespaced_input'
236
289
  http_method :post
@@ -246,18 +299,70 @@ describe HaveAPI::Action do
246
299
  end
247
300
 
248
301
  output do
249
- bool :params_saw_secret
302
+ bool :has_params_method
250
303
  bool :input_saw_secret
251
304
  end
252
305
 
253
306
  def exec
254
307
  {
255
- params_saw_secret: params.has_key?(:secret),
308
+ has_params_method: respond_to?(:params),
256
309
  input_saw_secret: input.has_key?(:secret)
257
310
  }
258
311
  end
259
312
  end
260
313
 
314
+ define_action(:UnnamespacedPathInput) do
315
+ route 'unnamespaced_input/{test_id}'
316
+ http_method :put
317
+ authorize { allow }
318
+
319
+ input(:hash, namespace: false) do
320
+ string :public
321
+ string :test_id
322
+ end
323
+
324
+ output do
325
+ bool :input_saw_test_id
326
+ string :input_test_id, nullable: true
327
+ string :path_test_id
328
+ end
329
+
330
+ def exec
331
+ {
332
+ input_saw_test_id: input.has_key?(:test_id),
333
+ input_test_id: input[:test_id],
334
+ path_test_id: path_params['test_id']
335
+ }
336
+ end
337
+ end
338
+
339
+ define_action(:QueryPathInput) do
340
+ route 'query_input/{test_id}'
341
+ http_method :get
342
+ authorize { allow }
343
+
344
+ input(:hash, namespace: false) do
345
+ string :filter
346
+ string :test_id
347
+ end
348
+
349
+ output do
350
+ bool :has_params_method
351
+ bool :input_saw_test_id
352
+ string :filter
353
+ string :path_test_id
354
+ end
355
+
356
+ def exec
357
+ {
358
+ has_params_method: respond_to?(:params),
359
+ input_saw_test_id: input.has_key?(:test_id),
360
+ filter: input[:filter],
361
+ path_test_id: path_params['test_id']
362
+ }
363
+ end
364
+ end
365
+
261
366
  define_action(:TopLevelBody) do
262
367
  route 'top_level_body'
263
368
  http_method :post
@@ -273,18 +378,76 @@ describe HaveAPI::Action do
273
378
  end
274
379
 
275
380
  output do
276
- bool :params_saw_secret
381
+ bool :has_params_method
277
382
  bool :input_saw_secret
278
383
  end
279
384
 
280
385
  def exec
281
386
  {
282
- params_saw_secret: params.has_key?(:secret),
387
+ has_params_method: respond_to?(:params),
283
388
  input_saw_secret: input.has_key?(:secret)
284
389
  }
285
390
  end
286
391
  end
287
392
 
393
+ define_action(:CustomPayload) do
394
+ route 'custom_payload'
395
+ http_method :post
396
+ authorize { allow }
397
+
398
+ input do
399
+ custom :payload
400
+ end
401
+
402
+ output do
403
+ bool :top_string_key
404
+ bool :top_symbol_key
405
+ bool :nested_string_key
406
+ bool :nested_symbol_key
407
+ end
408
+
409
+ def exec
410
+ payload = input[:payload]
411
+ nested = payload['response']
412
+
413
+ {
414
+ top_string_key: payload.has_key?('rawId'),
415
+ top_symbol_key: payload.has_key?(:rawId),
416
+ nested_string_key: nested.has_key?('clientDataJSON'),
417
+ nested_symbol_key: nested.has_key?(:clientDataJSON)
418
+ }
419
+ end
420
+ end
421
+
422
+ define_action(:CustomPayloadSymbols) do
423
+ route 'custom_payload_symbols'
424
+ http_method :post
425
+ authorize { allow }
426
+
427
+ input do
428
+ custom :payload, symbolize_keys: true
429
+ end
430
+
431
+ output do
432
+ bool :top_string_key
433
+ bool :top_symbol_key
434
+ bool :nested_string_key
435
+ bool :nested_symbol_key
436
+ end
437
+
438
+ def exec
439
+ payload = input[:payload]
440
+ nested = payload[:response]
441
+
442
+ {
443
+ top_string_key: payload.has_key?('rawId'),
444
+ top_symbol_key: payload.has_key?(:rawId),
445
+ nested_string_key: nested.has_key?('clientDataJSON'),
446
+ nested_symbol_key: nested.has_key?(:clientDataJSON)
447
+ }
448
+ end
449
+ end
450
+
288
451
  define_action(:Closed) do
289
452
  route 'closed'
290
453
  http_method :post
@@ -309,7 +472,7 @@ describe HaveAPI::Action do
309
472
  end
310
473
 
311
474
  def exec
312
- { id: params['test_id'] }
475
+ { id: path_params['test_id'] }
313
476
  end
314
477
  end
315
478
 
@@ -457,7 +620,7 @@ describe HaveAPI::Action do
457
620
  it 'rejects optional-only input namespaces with invalid shapes' do
458
621
  call_api([:Test], :optional_shape, { test: 'not-a-hash' })
459
622
 
460
- expect(last_response.status).to eq(400)
623
+ expect(last_response.status).to eq(200)
461
624
  expect(api_response).not_to be_ok
462
625
  expect(api_response.message).to eq('invalid input layout')
463
626
  end
@@ -465,7 +628,7 @@ describe HaveAPI::Action do
465
628
  it 'rejects list inputs with invalid element shapes' do
466
629
  call_api([:Test], :batch, { tests: ['not-a-hash'], _meta: { confirmed: true } })
467
630
 
468
- expect(last_response.status).to eq(400)
631
+ expect(last_response.status).to eq(200)
469
632
  expect(api_response).not_to be_ok
470
633
  expect(api_response.message).to eq('invalid input layout')
471
634
  end
@@ -473,13 +636,13 @@ describe HaveAPI::Action do
473
636
  it 'validates global metadata on list input actions' do
474
637
  call_api([:Test], :batch, { tests: [{ label: 'one' }] })
475
638
 
476
- expect(last_response.status).to eq(400)
639
+ expect(last_response.status).to eq(200)
477
640
  expect(api_response).not_to be_ok
478
641
  expect(api_response.errors[:confirmed]).to include('required parameter missing')
479
642
 
480
643
  call_api([:Test], :batch, { tests: [{ label: 'one' }], _meta: { confirmed: 'maybe' } })
481
644
 
482
- expect(last_response.status).to eq(400)
645
+ expect(last_response.status).to eq(200)
483
646
  expect(api_response).not_to be_ok
484
647
  expect(api_response.errors[:confirmed].first).to include('not a valid boolean')
485
648
  end
@@ -487,7 +650,7 @@ describe HaveAPI::Action do
487
650
  it 'rejects malformed metadata namespaces' do
488
651
  call_api([:Test], :echo, { test: { msg: 'hi' }, _meta: 'not-a-hash' })
489
652
 
490
- expect(last_response.status).to eq(400)
653
+ expect(last_response.status).to eq(200)
491
654
  expect(api_response).not_to be_ok
492
655
  expect(api_response.message).to eq('invalid input layout')
493
656
  end
@@ -558,16 +721,68 @@ describe HaveAPI::Action do
558
721
  expect(api_response.response[:_meta]).not_to have_key(:secret_status)
559
722
  end
560
723
 
561
- it 'filters unnamespaced input in the safe params view' do
724
+ it 'applies metadata-specific output filters to global metadata' do
725
+ call_api([:Test], :metadata_specific_output, {})
726
+
727
+ expect(last_response.status).to eq(200)
728
+ expect(api_response).to be_ok
729
+ expect(api_response[:test]).to include(ok: true)
730
+ expect(api_response[:test]).not_to have_key(:hidden_body)
731
+ expect(api_response.response[:_meta]).to include(public_status: 'queued')
732
+ expect(api_response.response[:_meta]).not_to have_key(:secret_status)
733
+ end
734
+
735
+ it 'keeps global metadata when body output uses a whitelist' do
736
+ get '/v1/tests/whitelisted', { _meta: { count: true } }, input: ''
737
+
738
+ expect(last_response.status).to eq(200)
739
+ expect(api_response).to be_ok
740
+ expect(api_response.response[:_meta]).to include(total_count: 2)
741
+ end
742
+
743
+ it 'filters unnamespaced input without exposing legacy params' do
562
744
  call_api([:Test], :unnamespaced_input, { public: 'ok', secret: 'hidden' })
563
745
 
564
746
  expect(last_response.status).to eq(200)
565
747
  expect(api_response).to be_ok
566
- expect(api_response[:test][:params_saw_secret]).to be(false)
748
+ expect(api_response[:test][:has_params_method]).to be(false)
567
749
  expect(api_response[:test][:input_saw_secret]).to be(false)
568
750
  end
569
751
 
570
- it 'does not expose top-level JSON keys outside the input namespace as safe params' do
752
+ it 'keeps path parameters out of unnamespaced body input' do
753
+ call_api(:put, '/v1/tests/unnamespaced_input/route-id', { public: 'ok' })
754
+
755
+ expect(last_response.status).to eq(200)
756
+ expect(api_response).to be_ok
757
+ expect(api_response[:test][:input_saw_test_id]).to be(false)
758
+ expect(api_response[:test][:path_test_id]).to eq('route-id')
759
+ end
760
+
761
+ it 'exposes client values through input without changing path identity' do
762
+ call_api(:put, '/v1/tests/unnamespaced_input/route-id', {
763
+ public: 'ok',
764
+ test_id: 'body-id'
765
+ })
766
+
767
+ expect(last_response.status).to eq(200)
768
+ expect(api_response).to be_ok
769
+ expect(api_response[:test][:input_saw_test_id]).to be(true)
770
+ expect(api_response[:test][:input_test_id]).to eq('body-id')
771
+ expect(api_response[:test][:path_test_id]).to eq('route-id')
772
+ end
773
+
774
+ it 'keeps route ids out of GET query input' do
775
+ get '/v1/tests/query_input/route-id', { filter: 'visible' }, input: ''
776
+
777
+ expect(last_response.status).to eq(200)
778
+ expect(api_response).to be_ok
779
+ expect(api_response[:test][:has_params_method]).to be(false)
780
+ expect(api_response[:test][:input_saw_test_id]).to be(false)
781
+ expect(api_response[:test][:filter]).to eq('visible')
782
+ expect(api_response[:test][:path_test_id]).to eq('route-id')
783
+ end
784
+
785
+ it 'does not expose top-level JSON keys outside the input namespace' do
571
786
  call_api([:Test], :top_level_body, {
572
787
  secret: 'top-level hidden',
573
788
  test: {
@@ -578,10 +793,50 @@ describe HaveAPI::Action do
578
793
 
579
794
  expect(last_response.status).to eq(200)
580
795
  expect(api_response).to be_ok
581
- expect(api_response[:test][:params_saw_secret]).to be(false)
796
+ expect(api_response[:test][:has_params_method]).to be(false)
582
797
  expect(api_response[:test][:input_saw_secret]).to be(false)
583
798
  end
584
799
 
800
+ it 'keeps custom payload field names string-keyed by default' do
801
+ call_api([:Test], :custom_payload, {
802
+ test: {
803
+ payload: {
804
+ rawId: 'credential-id',
805
+ response: {
806
+ clientDataJSON: 'client-data'
807
+ }
808
+ }
809
+ }
810
+ })
811
+
812
+ expect(last_response.status).to eq(200)
813
+ expect(api_response).to be_ok
814
+ expect(api_response[:test][:top_string_key]).to be(true)
815
+ expect(api_response[:test][:top_symbol_key]).to be(false)
816
+ expect(api_response[:test][:nested_string_key]).to be(true)
817
+ expect(api_response[:test][:nested_symbol_key]).to be(false)
818
+ end
819
+
820
+ it 'symbolizes custom payload field names when requested' do
821
+ call_api([:Test], :custom_payload_symbols, {
822
+ test: {
823
+ payload: {
824
+ rawId: 'credential-id',
825
+ response: {
826
+ clientDataJSON: 'client-data'
827
+ }
828
+ }
829
+ }
830
+ })
831
+
832
+ expect(last_response.status).to eq(200)
833
+ expect(api_response).to be_ok
834
+ expect(api_response[:test][:top_string_key]).to be(false)
835
+ expect(api_response[:test][:top_symbol_key]).to be(true)
836
+ expect(api_response[:test][:nested_string_key]).to be(false)
837
+ expect(api_response[:test][:nested_symbol_key]).to be(true)
838
+ end
839
+
585
840
  it 'denies OPTIONS for actions without an authorization block without raising' do
586
841
  call_api(:options, '/v1/tests/closed?method=POST')
587
842
 
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ describe HaveAPI::Action do
4
+ describe 'validation error HTTP status' do
5
+ context 'with compatibility behavior' do
6
+ api do
7
+ define_resource(:ValidationStatus) do
8
+ version 1
9
+ auth false
10
+
11
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
12
+ authorize { allow }
13
+
14
+ input do
15
+ string :name, required: true
16
+ end
17
+
18
+ output do
19
+ string :name
20
+ end
21
+
22
+ def exec
23
+ { name: input[:name] }
24
+ end
25
+ end
26
+ end
27
+ end
28
+
29
+ default_version 1
30
+
31
+ it 'keeps validation envelopes on HTTP 200 by default' do
32
+ call_api([:ValidationStatus], :create, { validation_status: {} })
33
+
34
+ expect(last_response.status).to eq(200)
35
+ expect(api_response).not_to be_ok
36
+ expect(api_response.errors[:name]).to include('required parameter missing')
37
+ end
38
+ end
39
+
40
+ context 'with configured HTTP 400 status' do
41
+ api do
42
+ define_resource(:StrictValidationStatus) do
43
+ version 1
44
+ auth false
45
+
46
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
47
+ authorize { allow }
48
+
49
+ input do
50
+ string :name, required: true
51
+ end
52
+
53
+ output do
54
+ string :name
55
+ end
56
+
57
+ def exec
58
+ { name: input[:name] }
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ default_version 1
65
+ validation_error_http_status 400
66
+
67
+ it 'returns HTTP 400 for validation envelopes' do
68
+ call_api([:StrictValidationStatus], :create, { strict_validation_status: {} })
69
+
70
+ expect(last_response.status).to eq(400)
71
+ expect(api_response).not_to be_ok
72
+ expect(api_response.errors[:name]).to include('required parameter missing')
73
+ end
74
+ end
75
+ end
76
+ end
@@ -225,7 +225,7 @@ describe HaveAPI::Resources::ActionState do
225
225
 
226
226
  get_action '/v1/action_states/1/poll', action_state: { timeout: 31 }
227
227
 
228
- expect(last_response.status).to eq(400)
228
+ expect(last_response.status).to eq(200)
229
229
  expect(api_response).not_to be_ok
230
230
  expect(api_response.errors[:timeout].first).to include('range <0, 30>')
231
231
  end
@@ -333,12 +333,12 @@ describe HaveAPI::Resources::ActionState do
333
333
  header 'Accept', 'application/json'
334
334
  end
335
335
 
336
- it 'requires authentication for action state resources' do
336
+ it 'uses backend-defined anonymous behavior by default' do
337
337
  get_action '/v1/action_states'
338
338
 
339
- expect(last_response.status).to eq(401)
340
- expect(api_response).not_to be_ok
341
- expect(ActionStateSpec::Backend.list_calls).to be_empty
339
+ expect(last_response.status).to eq(200)
340
+ expect(api_response).to be_ok
341
+ expect(ActionStateSpec::Backend.list_calls.last[:user]).to be_nil
342
342
  end
343
343
 
344
344
  it 'passes authenticated users to the action state backend' do
@@ -350,4 +350,27 @@ describe HaveAPI::Resources::ActionState do
350
350
  expect(ActionStateSpec::Backend.list_calls.last[:user].id).to eq(1)
351
351
  end
352
352
  end
353
+
354
+ context 'with action_state auth required' do
355
+ empty_api
356
+ use_version 1
357
+ default_version 1
358
+ action_state ActionStateSpec::Backend
359
+ action_state_auth :required
360
+ auth_chain ActionStateSpec::BasicProvider
361
+
362
+ before do
363
+ ActionStateSpec::Backend.reset!
364
+ ActionStateSpec::Backend.add_state(ActionStateSpec::State.new(id: 1))
365
+ header 'Accept', 'application/json'
366
+ end
367
+
368
+ it 'requires authentication before reaching the action state backend' do
369
+ get_action '/v1/action_states'
370
+
371
+ expect(last_response.status).to eq(401)
372
+ expect(api_response).not_to be_ok
373
+ expect(ActionStateSpec::Backend.list_calls).to be_empty
374
+ end
375
+ end
353
376
  end
@@ -142,7 +142,7 @@ describe DocAuthFilteringSpec do
142
142
 
143
143
  # rubocop:disable RSpec/NoExpectationExample
144
144
  example 'admin-only public result' do
145
- authorize { |user| user&.login == 'admin' }
145
+ authorize { |user| user.login == 'admin' }
146
146
  response({ msg: 'ADMIN_ONLY_RESULT' })
147
147
  end
148
148
  # rubocop:enable RSpec/NoExpectationExample
@@ -242,13 +242,25 @@ describe DocAuthFilteringSpec do
242
242
  expect(api_response[:method]).to eq('GET')
243
243
  end
244
244
 
245
- it 'hides examples denied to anonymous users from version docs' do
245
+ it 'includes examples for anonymous version docs without evaluating example auth' do
246
246
  header 'Authorization', nil
247
247
  call_api(:options, '/v1/')
248
248
 
249
249
  expect(last_response.status).to eq(200)
250
250
  expect(api_response).to be_ok
251
251
 
252
+ examples = api_response[:resources][:secure][:actions][:public][:examples]
253
+ expect(examples.size).to eq(1)
254
+ expect(examples.first[:response][:msg]).to eq('ADMIN_ONLY_RESULT')
255
+ end
256
+
257
+ it 'hides examples denied to authenticated users from version docs' do
258
+ login('user', 'pass')
259
+ call_api(:options, '/v1/')
260
+
261
+ expect(last_response.status).to eq(200)
262
+ expect(api_response).to be_ok
263
+
252
264
  examples = api_response[:resources][:secure][:actions][:public][:examples]
253
265
  expect(examples).to be_empty
254
266
  end