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
@@ -93,6 +93,27 @@ describe HaveAPI::Extensions::ActionExceptions do
93
93
  )
94
94
  end
95
95
 
96
+ def with_action_exceptions_without_handlers
97
+ hooks = HaveAPI::Hooks.hooks
98
+ action_hooks = hooks[HaveAPI::Action][:exec_exception]
99
+ original_listeners = action_hooks[:listeners].dup
100
+ original_exceptions =
101
+ HaveAPI::Extensions::ActionExceptions.instance_variable_get(:@exceptions)
102
+
103
+ if HaveAPI::Extensions::ActionExceptions.instance_variable_defined?(:@exceptions)
104
+ HaveAPI::Extensions::ActionExceptions.remove_instance_variable(:@exceptions)
105
+ end
106
+ HaveAPI::Extensions::ActionExceptions.enabled(app.settings.api_server)
107
+
108
+ yield
109
+ ensure
110
+ action_hooks[:listeners] = original_listeners
111
+ HaveAPI::Extensions::ActionExceptions.instance_variable_set(
112
+ :@exceptions,
113
+ original_exceptions
114
+ )
115
+ end
116
+
96
117
  def map_exception(klass, status)
97
118
  HaveAPI::Extensions::ActionExceptions.rescue(klass) do |ret, e|
98
119
  ret[:status] = false
@@ -151,6 +172,15 @@ describe HaveAPI::Extensions::ActionExceptions do
151
172
  end
152
173
  end
153
174
 
175
+ it 'keeps exceptions in a safe envelope when no handlers are registered' do
176
+ with_action_exceptions_without_handlers do
177
+ call_test_action(:raise_runtime)
178
+
179
+ expect_failed_json(500)
180
+ expect(api_response.message).not_to be_empty
181
+ end
182
+ end
183
+
154
184
  it 'does not interfere with successful responses' do
155
185
  with_action_exceptions do
156
186
  call_test_action(:ok)
@@ -29,6 +29,24 @@ module ARAdapterSpec
29
29
  validates :score, numericality: { equal_to: 7 }
30
30
  validates :name, presence: true
31
31
  end
32
+
33
+ class FilteredMember < ActiveRecord::Base
34
+ self.table_name = 'users'
35
+
36
+ belongs_to :group, class_name: 'ARAdapterSpec::Group', optional: true
37
+ end
38
+
39
+ class HiddenAccount < ActiveRecord::Base
40
+ self.table_name = 'hidden_accounts'
41
+ end
42
+
43
+ class Invoice < ActiveRecord::Base
44
+ belongs_to :hidden_account, class_name: 'ARAdapterSpec::HiddenAccount'
45
+ end
46
+
47
+ class StringAccount < ActiveRecord::Base
48
+ self.primary_key = 'uuid'
49
+ end
32
50
  end
33
51
 
34
52
  describe HaveAPI::ModelAdapters::ActiveRecord do
@@ -94,12 +112,77 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
94
112
  resource env_resource
95
113
  end
96
114
 
115
+ def prepare
116
+ group = self.class.model.find(params['group_id'])
117
+ error!('access denied') if group.note == 'PRIVATE_GROUP_NOTE'
118
+ end
119
+
97
120
  def exec
98
121
  self.class.model.find(params['group_id'])
99
122
  end
100
123
  end
101
124
  end
102
125
 
126
+ filtered_group_resource = define_resource(:FilteredGroup) do
127
+ version 1
128
+ auth false
129
+ model ARAdapterSpec::Group
130
+
131
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
132
+ authorize do
133
+ output blacklist: [:note]
134
+ allow
135
+ end
136
+
137
+ output(:object_list) do
138
+ integer :id
139
+ string :label
140
+ string :note
141
+ end
142
+
143
+ def exec
144
+ self.class.model.order(id: :asc).to_a
145
+ end
146
+ end
147
+
148
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
149
+ authorize do
150
+ output blacklist: [:note]
151
+ allow
152
+ end
153
+
154
+ output(:object) do
155
+ integer :id
156
+ string :label
157
+ string :note
158
+ end
159
+
160
+ def exec
161
+ self.class.model.find(params['filtered_group_id'])
162
+ end
163
+ end
164
+ end
165
+
166
+ define_resource(:FilteredMember) do
167
+ version 1
168
+ auth false
169
+ model ARAdapterSpec::FilteredMember
170
+
171
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
172
+ authorize { allow }
173
+
174
+ output(:object) do
175
+ integer :id
176
+ string :name
177
+ resource filtered_group_resource, name: :group
178
+ end
179
+
180
+ def exec
181
+ with_includes(self.class.model.where(id: params['filtered_member_id'])).take!
182
+ end
183
+ end
184
+ end
185
+
103
186
  define_resource(:User) do
104
187
  version 1
105
188
  auth false
@@ -147,6 +230,42 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
147
230
  end
148
231
  end
149
232
 
233
+ define_action(:PublicShow, superclass: HaveAPI::Actions::Default::Show) do
234
+ route 'public/{user_id}/show'
235
+
236
+ authorize do
237
+ output whitelist: [:name]
238
+ allow
239
+ end
240
+
241
+ output(:object) do
242
+ integer :id
243
+ string :name
244
+ end
245
+
246
+ def exec
247
+ self.class.model.find(params['user_id'])
248
+ end
249
+ end
250
+
251
+ define_action(:PublicIndex, superclass: HaveAPI::Actions::Default::Index) do
252
+ route 'public/list'
253
+
254
+ authorize do
255
+ output whitelist: [:name]
256
+ allow
257
+ end
258
+
259
+ output(:object_list) do
260
+ integer :id
261
+ string :name
262
+ end
263
+
264
+ def exec
265
+ self.class.model.order(id: :asc).to_a
266
+ end
267
+ end
268
+
150
269
  define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
151
270
  authorize { allow }
152
271
 
@@ -164,6 +283,75 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
164
283
  end
165
284
  end
166
285
  end
286
+
287
+ hidden_account_resource = define_resource(:HiddenAccount) do
288
+ version 1
289
+ auth false
290
+ model ARAdapterSpec::HiddenAccount
291
+
292
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
293
+ authorize { deny }
294
+
295
+ output(:object_list) do
296
+ integer :id
297
+ string :label
298
+ end
299
+
300
+ def exec
301
+ self.class.model.order(id: :asc).to_a
302
+ end
303
+ end
304
+
305
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
306
+ authorize { deny }
307
+
308
+ output(:object) do
309
+ integer :id
310
+ string :label
311
+ string :private_reference
312
+ end
313
+
314
+ def exec
315
+ self.class.model.find(params['hidden_account_id'])
316
+ end
317
+ end
318
+ end
319
+
320
+ define_resource(:Invoice) do
321
+ version 1
322
+ auth false
323
+ model ARAdapterSpec::Invoice
324
+
325
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
326
+ authorize { allow }
327
+
328
+ output(:object) do
329
+ integer :id
330
+ string :label
331
+ resource hidden_account_resource
332
+ end
333
+
334
+ def exec
335
+ self.class.model.find(params['invoice_id'])
336
+ end
337
+ end
338
+
339
+ define_action(:Update, superclass: HaveAPI::Actions::Default::Update) do
340
+ authorize { allow }
341
+
342
+ input(:hash) do
343
+ resource hidden_account_resource
344
+ end
345
+
346
+ output(:hash) do
347
+ bool :assigned
348
+ end
349
+
350
+ def exec
351
+ { assigned: input.has_key?(:hidden_account) }
352
+ end
353
+ end
354
+ end
167
355
  end
168
356
 
169
357
  default_version 1
@@ -193,6 +381,21 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
193
381
  t.string :state
194
382
  t.integer :group_id
195
383
  end
384
+
385
+ create_table :hidden_accounts do |t|
386
+ t.string :label, null: false
387
+ t.string :private_reference, null: false
388
+ end
389
+
390
+ create_table :invoices do |t|
391
+ t.string :label, null: false
392
+ t.integer :hidden_account_id, null: false
393
+ end
394
+
395
+ create_table :string_accounts, id: false do |t|
396
+ t.string :uuid, primary_key: true
397
+ t.string :label, null: false
398
+ end
196
399
  end
197
400
  end
198
401
 
@@ -200,6 +403,9 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
200
403
  ARAdapterSpec::User.delete_all
201
404
  ARAdapterSpec::Group.delete_all
202
405
  ARAdapterSpec::Environment.delete_all
406
+ ARAdapterSpec::Invoice.delete_all
407
+ ARAdapterSpec::HiddenAccount.delete_all
408
+ ARAdapterSpec::StringAccount.delete_all
203
409
  end
204
410
 
205
411
  let(:dummy_action) do
@@ -275,7 +481,7 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
275
481
  end
276
482
 
277
483
  it 'parses nested includes and drops unknown associations' do
278
- parsed = dummy_action.ar_parse_includes(%w[group group__environment foo bar__baz])
484
+ parsed = dummy_action.ar_parse_includes(%w[group group__environment group__missing foo bar__baz])
279
485
 
280
486
  expect(parsed).to include(:group)
281
487
  expect(parsed.any? { |v| v.is_a?(Hash) && v.has_key?(:group) }).to be(true)
@@ -283,10 +489,17 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
283
489
  nested = parsed.detect { |v| v.is_a?(Hash) && v.has_key?(:group) }
284
490
  expect(nested[:group].flatten).to include(:environment)
285
491
 
492
+ expect(nested[:group].flatten).not_to include(:missing)
286
493
  expect(parsed).not_to include(:foo)
287
494
  expect(parsed).not_to include(:bar)
288
495
  end
289
496
 
497
+ it 'drops overly deep include paths before building ActiveRecord includes' do
498
+ deep_path = (['missing'] * 5_000).join('__')
499
+
500
+ expect(dummy_action.ar_parse_includes([deep_path])).to eq([])
501
+ end
502
+
290
503
  it 'returns unresolved associations without includes' do
291
504
  environment = ARAdapterSpec::Environment.create!(label: 'env', note: 'ENV_NOTE')
292
505
  group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE', environment: environment)
@@ -320,6 +533,23 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
320
533
  expect(group_data[:environment]).not_to have_key(:note)
321
534
  end
322
535
 
536
+ it 'applies associated show output restrictions when included' do
537
+ group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GROUP_SECRET')
538
+ user = create_user(name: 'user', group: group)
539
+
540
+ get "/v1/filtered_groups/#{group.id}", {}, input: ''
541
+ expect(api_response).to be_ok
542
+ expect(api_response[:filtered_group]).not_to have_key(:note)
543
+
544
+ get "/v1/filtered_members/#{user.id}", { _meta: { includes: 'group' } }, input: ''
545
+
546
+ expect(api_response).to be_ok
547
+ group_data = api_response[:filtered_member][:group]
548
+ expect(group_data[:_meta][:resolved]).to be(true)
549
+ expect(group_data).to include(id: group.id, label: 'grp')
550
+ expect(group_data).not_to have_key(:note)
551
+ end
552
+
323
553
  it 'resolves nested associations when included' do
324
554
  environment = ARAdapterSpec::Environment.create!(label: 'env', note: 'ENV_NOTE')
325
555
  group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE', environment: environment)
@@ -337,14 +567,137 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
337
567
  expect(env_data[:note]).to eq('ENV_NOTE')
338
568
  end
339
569
 
570
+ it 'drops invalid nested include paths from requests' do
571
+ group = ARAdapterSpec::Group.create!(label: 'grp', note: 'GRP_NOTE')
572
+ user = create_user(name: 'user', group: group)
573
+
574
+ get "/v1/users/#{user.id}", { _meta: { includes: 'group__missing' } }, input: ''
575
+
576
+ expect(last_response.status).to eq(200)
577
+ expect(api_response).to be_ok
578
+ expect(api_response[:user][:group][:_meta][:resolved]).to be(false)
579
+ end
580
+
581
+ it 'does not expose unresolved associated resources denied by their show action' do
582
+ account = ARAdapterSpec::HiddenAccount.create!(
583
+ label: 'VIP billing account',
584
+ private_reference: 'SECRET-ACCOUNT-REF'
585
+ )
586
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
587
+
588
+ get "/v1/hidden_accounts/#{account.id}", {}, input: ''
589
+ expect(last_response.status).to eq(403)
590
+ expect(api_response).to be_failed
591
+
592
+ get "/v1/invoices/#{invoice.id}", {}, input: ''
593
+
594
+ expect(last_response.status).to eq(200)
595
+ expect(api_response).to be_ok
596
+
597
+ account_data = api_response[:invoice][:hidden_account]
598
+ expect(account_data).to eq(_meta: { resolved: false, authorized: false })
599
+ end
600
+
601
+ it 'does not resolve associated resources denied by their show action' do
602
+ account = ARAdapterSpec::HiddenAccount.create!(
603
+ label: 'VIP billing account',
604
+ private_reference: 'SECRET-ACCOUNT-REF'
605
+ )
606
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
607
+
608
+ get "/v1/invoices/#{invoice.id}", { _meta: { includes: 'hidden_account' } }, input: ''
609
+
610
+ expect(last_response.status).to eq(200)
611
+ expect(api_response).to be_ok
612
+
613
+ account_data = api_response[:invoice][:hidden_account]
614
+ expect(account_data).to eq(_meta: { resolved: false, authorized: false })
615
+ end
616
+
617
+ it 'does not expose unresolved associated resources denied during show prepare' do
618
+ group = ARAdapterSpec::Group.create!(
619
+ label: 'private group',
620
+ note: 'PRIVATE_GROUP_NOTE'
621
+ )
622
+ user = create_user(name: 'user', group: group)
623
+
624
+ get "/v1/groups/#{group.id}", {}, input: ''
625
+ expect(api_response).to be_failed
626
+
627
+ get "/v1/users/#{user.id}", {}, input: ''
628
+
629
+ expect(last_response.status).to eq(200)
630
+ expect(api_response).to be_ok
631
+
632
+ group_data = api_response[:user][:group]
633
+ expect(group_data).to eq(_meta: { resolved: false, authorized: false })
634
+ end
635
+
636
+ it 'does not resolve associated resources denied during show prepare' do
637
+ group = ARAdapterSpec::Group.create!(
638
+ label: 'private group',
639
+ note: 'PRIVATE_GROUP_NOTE'
640
+ )
641
+ user = create_user(name: 'user', group: group)
642
+
643
+ get "/v1/users/#{user.id}", { _meta: { includes: 'group' } }, input: ''
644
+
645
+ expect(last_response.status).to eq(200)
646
+ expect(api_response).to be_ok
647
+
648
+ group_data = api_response[:user][:group]
649
+ expect(group_data).to eq(_meta: { resolved: false, authorized: false })
650
+ end
651
+
652
+ it 'does not expose object path params when id output is filtered' do
653
+ user = create_user(name: 'user')
654
+
655
+ get "/v1/users/public/#{user.id}/show", {}, input: ''
656
+
657
+ expect(last_response.status).to eq(200)
658
+ expect(api_response).to be_ok
659
+ expect(api_response[:user]).to eq(name: 'user')
660
+ expect(api_response.response[:_meta]).not_to have_key(:path_params)
661
+ end
662
+
663
+ it 'does not expose object-list path params when id output is filtered' do
664
+ create_user(id: 1, name: 'user1')
665
+ create_user(id: 2, name: 'user2')
666
+
667
+ get '/v1/users/public/list', {}, input: ''
668
+
669
+ expect(last_response.status).to eq(200)
670
+ expect(api_response).to be_ok
671
+ expect(api_response[:users].map { |user| user[:name] }).to eq(%w[user1 user2])
672
+ expect(api_response[:users]).to all(satisfy { |user| !user[:_meta].has_key?(:path_params) })
673
+ end
674
+
675
+ it 'rejects resource input records denied by their show action' do
676
+ account = ARAdapterSpec::HiddenAccount.create!(
677
+ label: 'VIP billing account',
678
+ private_reference: 'SECRET-ACCOUNT-REF'
679
+ )
680
+ invoice = ARAdapterSpec::Invoice.create!(label: 'public invoice', hidden_account: account)
681
+
682
+ put "/v1/invoices/#{invoice.id}", {
683
+ invoice: {
684
+ hidden_account: account.id
685
+ }
686
+ }.to_json, 'CONTENT_TYPE' => 'application/json'
687
+
688
+ expect(last_response.status).to eq(400)
689
+ expect(api_response).not_to be_ok
690
+ expect(api_response.errors[:hidden_account]).to include('resource not found')
691
+ end
692
+
340
693
  it 'cleans resource input ids and maps invalid values to validation errors' do
341
694
  environment = ARAdapterSpec::Environment.create!(id: 1, label: 'env')
342
695
 
343
696
  expect(described_class::Input.clean(ARAdapterSpec::Environment, 1, {})).to eq(environment)
344
697
  expect(described_class::Input.clean(ARAdapterSpec::Environment, '1', {})).to eq(environment)
345
698
  expect(described_class::Input.clean(ARAdapterSpec::Environment, 1.0, {})).to eq(environment)
346
- expect(described_class::Input.clean(ARAdapterSpec::Environment, '', { optional: true })).to be_nil
347
- expect(described_class::Input.clean(ARAdapterSpec::Environment, ' ', { optional: true })).to be_nil
699
+ expect(described_class::Input.clean(ARAdapterSpec::Environment, '', { nullable: true })).to be_nil
700
+ expect(described_class::Input.clean(ARAdapterSpec::Environment, ' ', { nullable: true })).to be_nil
348
701
 
349
702
  expect do
350
703
  described_class::Input.clean(ARAdapterSpec::Environment, 'abc', {})
@@ -371,6 +724,48 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
371
724
  end.to raise_error(HaveAPI::ValidationError, /resource not found/)
372
725
  end
373
726
 
727
+ it 'rejects nil results from non-nullable custom resource fetchers' do
728
+ fetch = proc { |id| find_by(id:) }
729
+
730
+ expect do
731
+ described_class::Input.clean(ARAdapterSpec::Environment, 9999, { fetch: })
732
+ end.to raise_error(HaveAPI::ValidationError, /resource not found/)
733
+
734
+ cleaned = described_class::Input.clean(
735
+ ARAdapterSpec::Environment,
736
+ 9999,
737
+ { fetch:, nullable: true }
738
+ )
739
+ expect(cleaned).to be_nil
740
+ end
741
+
742
+ it 'rejects arrays and hashes for singular string primary-key resources' do
743
+ ARAdapterSpec::StringAccount.create!(uuid: 'acct-alpha', label: 'Alpha')
744
+ ARAdapterSpec::StringAccount.create!(uuid: 'acct-beta', label: 'Beta')
745
+
746
+ expect do
747
+ described_class::Input.clean(ARAdapterSpec::StringAccount, %w[acct-alpha acct-beta], {})
748
+ end.to raise_error(HaveAPI::ValidationError, /not a valid id/)
749
+
750
+ expect do
751
+ described_class::Input.clean(ARAdapterSpec::StringAccount, { id: 'acct-alpha' }, {})
752
+ end.to raise_error(HaveAPI::ValidationError, /not a valid id/)
753
+ end
754
+
755
+ it 'rejects non-string includes metadata entries' do
756
+ app
757
+ show_action = action_class(:User, :show)
758
+ includes_param = show_action.meta(:global).input[:includes]
759
+
760
+ expect do
761
+ includes_param.clean([{ bad: 'shape' }])
762
+ end.to raise_error(HaveAPI::ValidationError, /only strings/)
763
+ end
764
+
765
+ it 'keeps hash adapter resource cleaning compatible with adapter arguments' do
766
+ expect(HaveAPI::ModelAdapters::Hash::Input.clean({}, { id: 1 }, {})).to eq({ id: 1 })
767
+ end
768
+
374
769
  it 'applies ascending pagination with with_pagination' do
375
770
  5.times do |i|
376
771
  create_user(
@@ -388,6 +783,16 @@ describe HaveAPI::ModelAdapters::ActiveRecord do
388
783
  expect(ids).to eq([3, 4])
389
784
  end
390
785
 
786
+ it 'rejects excessive pagination limits' do
787
+ get '/v1/users', { user: { limit: HaveAPI::Actions::Paginable::MAX_LIMIT + 1 } }, input: ''
788
+
789
+ expect(last_response.status).to eq(400)
790
+ expect(api_response).not_to be_ok
791
+ expect(api_response.errors[:limit].first).to include(
792
+ "range <0, #{HaveAPI::Actions::Paginable::MAX_LIMIT}>"
793
+ )
794
+ end
795
+
391
796
  it 'applies descending pagination with with_desc_pagination' do
392
797
  5.times do |i|
393
798
  create_user(
@@ -1,4 +1,6 @@
1
1
  require 'time'
2
+ require 'open3'
3
+ require 'rbconfig'
2
4
 
3
5
  describe 'Parameters::Typed' do
4
6
  def p_type(type)
@@ -78,7 +80,8 @@ describe 'Parameters::Typed' do
78
80
  expect(p.clean(12.0)).to eq(12)
79
81
  expect { p.clean('abc') }.to raise_error(HaveAPI::ValidationError)
80
82
  expect { p.clean('12abc') }.to raise_error(HaveAPI::ValidationError)
81
- expect(p.clean('')).to be_nil
83
+ expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
84
+ expect { p.clean(nil) }.to raise_error(HaveAPI::ValidationError)
82
85
  expect { p.clean('12.0') }.to raise_error(HaveAPI::ValidationError)
83
86
  expect { p.clean(12.3) }.to raise_error(HaveAPI::ValidationError)
84
87
  expect { p.clean(true) }.to raise_error(HaveAPI::ValidationError)
@@ -92,7 +95,8 @@ describe 'Parameters::Typed' do
92
95
  expect(p.clean('1e3')).to eq(1000.0)
93
96
  expect(p.clean(3)).to eq(3.0)
94
97
  expect { p.clean('abc') }.to raise_error(HaveAPI::ValidationError)
95
- expect(p.clean('')).to be_nil
98
+ expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
99
+ expect { p.clean(nil) }.to raise_error(HaveAPI::ValidationError)
96
100
  expect { p.clean('NaN') }.to raise_error(HaveAPI::ValidationError)
97
101
  expect { p.clean(Float::NAN) }.to raise_error(HaveAPI::ValidationError)
98
102
  expect { p.clean(Float::INFINITY) }.to raise_error(HaveAPI::ValidationError)
@@ -115,7 +119,8 @@ describe 'Parameters::Typed' do
115
119
  expect(p.clean(1)).to be true
116
120
  expect(p.clean(' YES ')).to be true
117
121
  expect { p.clean('maybe') }.to raise_error(HaveAPI::ValidationError)
118
- expect(p.clean('')).to be_nil
122
+ expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
123
+ expect { p.clean(nil) }.to raise_error(HaveAPI::ValidationError)
119
124
  expect { p.clean(2) }.to raise_error(HaveAPI::ValidationError)
120
125
 
121
126
  p = p_arg(type: Boolean, required: true)
@@ -128,7 +133,9 @@ describe 'Parameters::Typed' do
128
133
 
129
134
  expect(p.clean(t.iso8601)).to eq(t2)
130
135
  expect { p.clean('bzz') }.to raise_error(HaveAPI::ValidationError)
131
- expect(p.clean('')).to be_nil
136
+ expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
137
+ expect { p.clean(nil) }.to raise_error(HaveAPI::ValidationError)
138
+ expect { p.clean([]) }.to raise_error(HaveAPI::ValidationError)
132
139
 
133
140
  p = p_arg(type: Datetime, required: true)
134
141
  expect { p.clean('') }.to raise_error(HaveAPI::ValidationError)
@@ -136,6 +143,7 @@ describe 'Parameters::Typed' do
136
143
  # String, Text
137
144
  p = p_type(String)
138
145
  expect(p.clean('bzz')).to eq('bzz')
146
+ expect(p.clean('')).to eq('')
139
147
  expect(p.clean(123)).to eq('123')
140
148
  expect(p.clean(true)).to eq('true')
141
149
  expect { p.clean([]) }.to raise_error(HaveAPI::ValidationError)
@@ -143,17 +151,56 @@ describe 'Parameters::Typed' do
143
151
 
144
152
  p = p_type(Text)
145
153
  expect(p.clean('bzz')).to eq('bzz')
154
+ expect(p.clean('')).to eq('')
146
155
  expect(p.clean(123)).to eq('123')
147
156
  expect(p.clean(true)).to eq('true')
148
157
  expect { p.clean([]) }.to raise_error(HaveAPI::ValidationError)
149
158
  expect { p.clean({}) }.to raise_error(HaveAPI::ValidationError)
150
159
 
151
- # Defaults
152
- p = p_type(String)
160
+ # Nullable
161
+ p = p_arg(type: Integer, nullable: true)
162
+ expect(p.clean('')).to be_nil
163
+ expect(p.clean(nil)).to be_nil
164
+
165
+ p = p_arg(type: Float, nullable: true)
166
+ expect(p.clean('')).to be_nil
167
+ expect(p.clean(nil)).to be_nil
168
+
169
+ p = p_arg(type: Boolean, nullable: true)
170
+ expect(p.clean('')).to be_nil
153
171
  expect(p.clean(nil)).to be_nil
154
172
 
173
+ p = p_arg(type: Datetime, nullable: true)
174
+ expect(p.clean('')).to be_nil
175
+ expect(p.clean(nil)).to be_nil
176
+
177
+ p = p_arg(type: String, nullable: true)
178
+ expect(p.clean('')).to be_nil
179
+ expect(p.clean(nil)).to be_nil
155
180
  p.patch(default: 'bazinga')
156
- expect(p.clean(nil)).to eq('bazinga')
181
+ expect(p.clean(nil)).to be_nil
182
+
183
+ p = p_arg(type: Text, nullable: true)
184
+ expect(p.clean('')).to be_nil
185
+ expect(p.clean(nil)).to be_nil
186
+ end
187
+
188
+ it 'rejects nil returned by custom cleaners unless nullable' do
189
+ p = p_arg(type: String, required: true, clean: proc {})
190
+ expect { p.clean('value') }.to raise_error(HaveAPI::ValidationError, /cannot be null/)
191
+
192
+ p = p_arg(type: String, nullable: true, clean: proc {})
193
+ expect(p.clean('value')).to be_nil
194
+ end
195
+
196
+ it 'rejects invalid string encoding during coercion' do
197
+ invalid = "\xff".b.force_encoding(Encoding::UTF_8)
198
+
199
+ expect { p_type(String).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
200
+ expect { p_type(Integer).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
201
+ expect { p_type(Float).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
202
+ expect { p_type(Boolean).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
203
+ expect { p_type(Datetime).clean(invalid) }.to raise_error(HaveAPI::ValidationError)
157
204
  end
158
205
 
159
206
  it 'can be protected' do
@@ -164,6 +211,27 @@ describe 'Parameters::Typed' do
164
211
  expect(p.describe(nil)[:protected]).to be false
165
212
  end
166
213
 
214
+ it 'loads Time#iso8601 for datetime output formatting' do
215
+ code = <<~RUBY
216
+ require 'haveapi/types'
217
+ require 'haveapi/parameters/typed'
218
+
219
+ param = HaveAPI::Parameters::Typed.new(:at, type: Datetime)
220
+ print param.format_output(Time.utc(1970, 1, 1))
221
+ RUBY
222
+
223
+ stdout, stderr, status = Open3.capture3(
224
+ RbConfig.ruby,
225
+ '-I',
226
+ File.expand_path('../../lib', __dir__),
227
+ '-e',
228
+ code
229
+ )
230
+
231
+ expect(status).to be_success, stderr
232
+ expect(stdout).to eq('1970-01-01T00:00:00Z')
233
+ end
234
+
167
235
  it 'is unprotected by default' do
168
236
  p = p_arg
169
237
  expect(p.describe(nil)[:protected]).to be false