haveapi 0.26.4 → 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
@@ -0,0 +1,583 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+ require_relative '../lib/haveapi'
6
+
7
+ module HaveAPI
8
+ module ClientTestAPI
9
+ FIXED_TIME = Time.utc(2020, 1, 1, 0, 0, 0)
10
+
11
+ User = Struct.new(:id, :login, :role)
12
+
13
+ USERS = {
14
+ 'user' => User.new(1, 'user', 'user'),
15
+ 'admin' => User.new(2, 'admin', 'admin')
16
+ }.freeze
17
+
18
+ class Model
19
+ def self.marker?
20
+ true
21
+ end
22
+ end
23
+
24
+ module ::HaveAPI::ModelAdapters
25
+ class ClientTestHash < ::HaveAPI::ModelAdapter
26
+ register
27
+
28
+ def self.handle?(_layout, klass)
29
+ klass == HaveAPI::ClientTestAPI::Model
30
+ end
31
+
32
+ class Input < ::HaveAPI::ModelAdapter::Input
33
+ def self.clean(_model, raw, _extra)
34
+ raw
35
+ end
36
+ end
37
+
38
+ class Output < ::HaveAPI::ModelAdapter::Output
39
+ def self.used_by(action)
40
+ action.meta(:object) do
41
+ output do
42
+ custom :path_params, label: 'URL parameters',
43
+ desc: 'An array of parameters needed to resolve URL to this object'
44
+ bool :resolved, label: 'Resolved', desc: 'True if the association is resolved'
45
+ end
46
+ end
47
+ end
48
+
49
+ def has_param?(name)
50
+ @object.has_key?(name) || @object.has_key?(name.to_s)
51
+ end
52
+
53
+ def [](name)
54
+ return @object[name] if @object.has_key?(name)
55
+
56
+ @object[name.to_s]
57
+ end
58
+
59
+ def meta
60
+ action = @context.action
61
+ resource = action.resource
62
+
63
+ params = if action.name.demodulize == 'Index' && !action.resolve && resource.const_defined?(:Show)
64
+ resource::Show.resolve_path_params(@object)
65
+ else
66
+ action.resolve_path_params(@object)
67
+ end
68
+
69
+ {
70
+ path_params: Array(params).compact,
71
+ resolved: true
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+
78
+ module Store
79
+ class << self
80
+ attr_reader :projects, :tasks
81
+
82
+ def reset!
83
+ @time_offset = 0
84
+ @projects = [
85
+ { id: 1, name: 'Alpha', created_at: FIXED_TIME },
86
+ { id: 2, name: 'Beta', created_at: FIXED_TIME }
87
+ ]
88
+ @tasks = {
89
+ 1 => [
90
+ { id: 1, project_id: 1, label: 'Initial task', done: false },
91
+ { id: 2, project_id: 1, label: 'Second task', done: true }
92
+ ],
93
+ 2 => []
94
+ }
95
+ @next_project_id = 3
96
+ @next_task_id = 3
97
+ end
98
+
99
+ def list_projects
100
+ @projects
101
+ end
102
+
103
+ def count_projects
104
+ @projects.size
105
+ end
106
+
107
+ def find_project(id)
108
+ @projects.find { |p| p[:id] == id.to_i }
109
+ end
110
+
111
+ def create_project(name)
112
+ project = { id: @next_project_id, name: name, created_at: next_time }
113
+ @next_project_id += 1
114
+ @projects << project
115
+ @tasks[project[:id]] ||= []
116
+ project
117
+ end
118
+
119
+ def list_tasks(project_id)
120
+ @tasks[project_id.to_i] || []
121
+ end
122
+
123
+ def find_task(project_id, task_id)
124
+ list_tasks(project_id).find { |t| t[:id] == task_id.to_i }
125
+ end
126
+
127
+ def create_task(project_id, label, done)
128
+ task = { id: @next_task_id, project_id: project_id.to_i, label: label, done: done }
129
+ @next_task_id += 1
130
+ (@tasks[project_id.to_i] ||= []) << task
131
+ task
132
+ end
133
+
134
+ def update_task(project_id, task_id, done)
135
+ task = find_task(project_id, task_id)
136
+ return nil unless task
137
+
138
+ task[:done] = done unless done.nil?
139
+ task
140
+ end
141
+
142
+ private
143
+
144
+ def next_time
145
+ @time_offset += 1
146
+ FIXED_TIME + @time_offset
147
+ end
148
+ end
149
+ end
150
+
151
+ module ActionStateBackend
152
+ class State
153
+ attr_reader :id, :label, :created_at, :updated_at, :status, :progress
154
+
155
+ def initialize(id:, label:, total:, status: true, can_cancel: true, valid: true)
156
+ @id = id
157
+ @label = label
158
+ @status = status
159
+ @can_cancel = can_cancel
160
+ @valid = valid
161
+ @progress = { current: 0, total: total, unit: 'step' }
162
+ @created_at = FIXED_TIME
163
+ @updated_at = FIXED_TIME
164
+ @finished = false
165
+ end
166
+
167
+ def valid?
168
+ @valid
169
+ end
170
+
171
+ def finished?
172
+ @finished
173
+ end
174
+
175
+ def can_cancel?
176
+ @can_cancel
177
+ end
178
+
179
+ def poll(_input)
180
+ return self if @finished
181
+
182
+ @progress[:current] += 1
183
+ if @progress[:current] >= @progress[:total]
184
+ @progress[:current] = @progress[:total]
185
+ @finished = true
186
+ end
187
+
188
+ @updated_at = Time.now.utc
189
+ self
190
+ end
191
+
192
+ def cancel
193
+ return false unless @can_cancel
194
+
195
+ @finished = true
196
+ @status = false
197
+ @progress[:current] = @progress[:total]
198
+ @updated_at = Time.now.utc
199
+ true
200
+ end
201
+ end
202
+
203
+ class << self
204
+ def reset!
205
+ @states = {}
206
+ @next_id = 1
207
+ end
208
+
209
+ def create_state(label:, total: 3, can_cancel: true)
210
+ id = @next_id
211
+ @next_id += 1
212
+ @states[id] = State.new(id: id, label: label, total: total, can_cancel: can_cancel)
213
+ id
214
+ end
215
+
216
+ def list_pending(_user, _from_id, _limit, _order)
217
+ (@states || {}).values.reject(&:finished?)
218
+ end
219
+
220
+ def new(_user, id:)
221
+ state = (@states || {})[id.to_i]
222
+ state || State.new(id: id.to_i, label: 'missing', total: 0, valid: false)
223
+ end
224
+ end
225
+ end
226
+
227
+ module DocFilter
228
+ def describe(context)
229
+ return false if auth && context.doc && context.current_user.nil?
230
+
231
+ super
232
+ end
233
+ end
234
+
235
+ class AuthFilteredResource < HaveAPI::Resource
236
+ def self.define_resource(name, superclass: AuthFilteredResource, &block)
237
+ super
238
+ end
239
+
240
+ def self.describe(hash, context)
241
+ ret = super
242
+ ret[:resources].delete_if do |_name, desc|
243
+ desc[:actions].empty? && desc[:resources].empty?
244
+ end
245
+ ret
246
+ end
247
+ end
248
+
249
+ module Resources
250
+ def self.define_resource(name, superclass: AuthFilteredResource, &block)
251
+ return false if const_defined?(name)
252
+
253
+ cls = Class.new(superclass)
254
+ const_set(name, cls)
255
+ cls.resource_name = name
256
+ cls.class_exec(&block) if block
257
+ cls
258
+ end
259
+
260
+ define_resource(:Project) do
261
+ desc 'Project resource'
262
+ auth true
263
+ model HaveAPI::ClientTestAPI::Model
264
+
265
+ params(:all) do
266
+ integer :id
267
+ string :name
268
+ datetime :created_at
269
+ end
270
+
271
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
272
+ extend DocFilter
273
+ resolve { |obj| obj[:id] }
274
+ output(:object_list) { use :all }
275
+ authorize { allow }
276
+
277
+ def exec
278
+ HaveAPI::ClientTestAPI::Store.list_projects
279
+ end
280
+
281
+ def count
282
+ HaveAPI::ClientTestAPI::Store.count_projects
283
+ end
284
+ end
285
+
286
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
287
+ extend DocFilter
288
+ resolve { |obj| obj[:id] }
289
+ output(:object) { use :all }
290
+ authorize { allow }
291
+
292
+ def exec
293
+ project = HaveAPI::ClientTestAPI::Store.find_project(params[:project_id])
294
+ error!('project not found', {}, http_status: 404) unless project
295
+ project
296
+ end
297
+ end
298
+
299
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
300
+ extend DocFilter
301
+ resolve { |obj| obj[:id] }
302
+ input(:hash) do
303
+ string :name, required: true
304
+ end
305
+ output(:object) { use :all }
306
+ authorize { allow }
307
+
308
+ def exec
309
+ HaveAPI::ClientTestAPI::Store.create_project(input[:name])
310
+ end
311
+ end
312
+
313
+ define_resource(:Task) do
314
+ desc 'Task resource'
315
+ auth true
316
+ route '{project_id}/tasks'
317
+ model HaveAPI::ClientTestAPI::Model
318
+
319
+ params(:all) do
320
+ integer :id
321
+ string :label
322
+ bool :done
323
+ end
324
+
325
+ define_action(:Index, superclass: HaveAPI::Actions::Default::Index) do
326
+ extend DocFilter
327
+ resolve { |obj| [obj[:project_id], obj[:id]] }
328
+ output(:object_list) { use :all }
329
+ authorize { allow }
330
+
331
+ def exec
332
+ HaveAPI::ClientTestAPI::Store.list_tasks(params[:project_id])
333
+ end
334
+
335
+ def count
336
+ HaveAPI::ClientTestAPI::Store.list_tasks(params[:project_id]).size
337
+ end
338
+ end
339
+
340
+ define_action(:Show, superclass: HaveAPI::Actions::Default::Show) do
341
+ extend DocFilter
342
+ resolve { |obj| [obj[:project_id], obj[:id]] }
343
+ output(:object) { use :all }
344
+ authorize { allow }
345
+
346
+ def exec
347
+ task = HaveAPI::ClientTestAPI::Store.find_task(params[:project_id], params[:task_id])
348
+ error!('task not found', {}, http_status: 404) unless task
349
+ task
350
+ end
351
+ end
352
+
353
+ define_action(:Create, superclass: HaveAPI::Actions::Default::Create) do
354
+ extend DocFilter
355
+ resolve { |obj| [obj[:project_id], obj[:id]] }
356
+ input(:hash) do
357
+ string :label, required: true
358
+ bool :done, default: false, fill: true
359
+ end
360
+ output(:object) { use :all }
361
+ authorize { allow }
362
+
363
+ def exec
364
+ HaveAPI::ClientTestAPI::Store.create_task(
365
+ params[:project_id],
366
+ input[:label],
367
+ input[:done]
368
+ )
369
+ end
370
+ end
371
+
372
+ define_action(:Update, superclass: HaveAPI::Actions::Default::Update) do
373
+ extend DocFilter
374
+ resolve { |obj| [obj[:project_id], obj[:id]] }
375
+ input(:hash) do
376
+ bool :done
377
+ end
378
+ output(:object) { use :all }
379
+ authorize { allow }
380
+
381
+ def exec
382
+ task = HaveAPI::ClientTestAPI::Store.update_task(
383
+ params[:project_id],
384
+ params[:task_id],
385
+ input[:done]
386
+ )
387
+ error!('task not found', {}, http_status: 404) unless task
388
+ task
389
+ end
390
+ end
391
+
392
+ define_action(:Run) do
393
+ extend DocFilter
394
+ route '{task_id}/run'
395
+ http_method :post
396
+ blocking true
397
+ output(:hash) {}
398
+ authorize { allow }
399
+
400
+ def exec
401
+ task = HaveAPI::ClientTestAPI::Store.find_task(params[:project_id], params[:task_id])
402
+ error!('task not found', {}, http_status: 404) unless task
403
+
404
+ @state_id = HaveAPI::ClientTestAPI::ActionStateBackend.create_state(
405
+ label: 'task-run',
406
+ total: 3,
407
+ can_cancel: true
408
+ )
409
+ {}
410
+ end
411
+
412
+ attr_reader :state_id
413
+ end
414
+ end
415
+ end
416
+
417
+ define_resource(:Test) do
418
+ desc 'Error testing resource'
419
+ auth false
420
+
421
+ define_action(:Fail) do
422
+ extend DocFilter
423
+ route 'fail'
424
+ http_method :get
425
+ output(:hash) {}
426
+ authorize { allow }
427
+
428
+ def exec
429
+ error!('forced failure', { base: ['forced failure'] }, http_status: 400)
430
+ end
431
+ end
432
+
433
+ define_action(:Echo) do
434
+ extend DocFilter
435
+ route 'echo'
436
+ http_method :post
437
+ input(:hash) do
438
+ integer :i, required: true
439
+ float :f, required: true
440
+ bool :b, required: true
441
+ datetime :dt, required: true
442
+ string :s, required: true
443
+ text :t, required: true
444
+ end
445
+ output(:hash) do
446
+ integer :i
447
+ float :f
448
+ bool :b
449
+ datetime :dt
450
+ string :s
451
+ text :t
452
+ end
453
+ authorize { allow }
454
+
455
+ def exec
456
+ input
457
+ end
458
+ end
459
+
460
+ define_action(:EchoResource) do
461
+ extend DocFilter
462
+ route 'echo_resource'
463
+ http_method :post
464
+ input(:hash) do
465
+ resource HaveAPI::ClientTestAPI::Resources::Project, required: true
466
+ end
467
+ output(:hash) do
468
+ integer :project, required: true
469
+ end
470
+ authorize { allow }
471
+
472
+ def exec
473
+ { project: input[:project] }
474
+ end
475
+ end
476
+ end
477
+ end
478
+
479
+ class BasicProvider < HaveAPI::Authentication::Basic::Provider
480
+ protected
481
+
482
+ def find_user(_request, username, password)
483
+ user = USERS[username]
484
+ return nil unless user
485
+ return nil unless password == 'pass'
486
+
487
+ user
488
+ end
489
+ end
490
+
491
+ class TokenConfig < HaveAPI::Authentication::Token::Config
492
+ class << self
493
+ def reset!
494
+ @tokens = {}
495
+ end
496
+
497
+ def tokens
498
+ @tokens ||= {}
499
+ end
500
+ end
501
+
502
+ request do
503
+ handle do |req, res|
504
+ input = req.input
505
+ user = USERS[input[:user]]
506
+
507
+ if user && input[:password] == 'pass'
508
+ token = "token-#{user.login}"
509
+ HaveAPI::ClientTestAPI::TokenConfig.tokens[token] = user
510
+ res.token = token
511
+ res.valid_to = Time.now + input[:interval].to_i
512
+ res.complete = true
513
+ res.ok
514
+ else
515
+ res.error = 'invalid credentials'
516
+ res
517
+ end
518
+ end
519
+ end
520
+
521
+ renew do
522
+ handle do |req, res|
523
+ if HaveAPI::ClientTestAPI::TokenConfig.tokens[req.token]
524
+ res.valid_to = Time.now + 3600
525
+ res.ok
526
+ else
527
+ res.error = 'unknown token'
528
+ res
529
+ end
530
+ end
531
+ end
532
+
533
+ revoke do
534
+ handle do |req, res|
535
+ HaveAPI::ClientTestAPI::TokenConfig.tokens.delete(req.token)
536
+ res.ok
537
+ end
538
+ end
539
+
540
+ def find_user_by_token(_request, token)
541
+ HaveAPI::ClientTestAPI::TokenConfig.tokens[token]
542
+ end
543
+ end
544
+
545
+ TokenProvider = HaveAPI::Authentication::Token.with_config(TokenConfig)
546
+
547
+ def self.reset!
548
+ Store.reset!
549
+ ActionStateBackend.reset!
550
+ TokenConfig.reset!
551
+ end
552
+
553
+ def self.build_server(base_url:)
554
+ HaveAPI.implicit_version = '1.0'
555
+
556
+ reset!
557
+
558
+ api = HaveAPI::Server.new(Resources)
559
+ api.use_version(:all)
560
+ api.default_version = '1.0'
561
+ api.auth_chain << BasicProvider
562
+ api.auth_chain << TokenProvider
563
+ api.action_state = ActionStateBackend
564
+
565
+ api.connect_hook(:pre_mount) do |ret, _server, sinatra|
566
+ sinatra.get '/__health' do
567
+ 'ok'
568
+ end
569
+
570
+ sinatra.post '/__reset' do
571
+ HaveAPI::ClientTestAPI.reset!
572
+ content_type 'application/json'
573
+ JSON.dump(ok: true)
574
+ end
575
+
576
+ ret
577
+ end
578
+
579
+ api.mount('/')
580
+ api
581
+ end
582
+ end
583
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'socket'
5
+ require 'webrick'
6
+
7
+ begin
8
+ require 'rackup'
9
+ handler = Rackup::Handler::WEBrick
10
+ rescue LoadError
11
+ require 'rack'
12
+ handler = Rack::Handler::WEBrick
13
+ end
14
+
15
+ require_relative 'client_test_api'
16
+
17
+ options = {
18
+ bind: '127.0.0.1',
19
+ port: 0
20
+ }
21
+
22
+ OptionParser.new do |opts|
23
+ opts.on('--bind HOST', 'Bind address (default 127.0.0.1)') do |v|
24
+ options[:bind] = v
25
+ end
26
+
27
+ opts.on('--port PORT', Integer, 'Port to listen on (default 0)') do |v|
28
+ options[:port] = v
29
+ end
30
+ end.parse!(ARGV)
31
+
32
+ bind = options[:bind]
33
+ port = options[:port]
34
+
35
+ if port == 0
36
+ tcp = TCPServer.new(bind, 0)
37
+ port = tcp.addr[1]
38
+ tcp.close
39
+ end
40
+
41
+ base_url = "http://#{bind}:#{port}"
42
+ api = HaveAPI::ClientTestAPI.build_server(base_url: base_url)
43
+
44
+ server = nil
45
+
46
+ trap('INT') { server&.shutdown }
47
+ trap('TERM') { server&.shutdown }
48
+
49
+ handler.run(
50
+ api.app,
51
+ Host: bind,
52
+ Port: port,
53
+ AccessLog: [],
54
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN)
55
+ ) do |srv|
56
+ server = srv
57
+ puts "HAVEAPI_TEST_SERVER_READY #{base_url}"
58
+ $stdout.flush
59
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: haveapi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.26.4
4
+ version: 0.27.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jakub Skokan
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: 0.26.4
32
+ version: 0.27.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: 0.26.4
39
+ version: 0.27.0
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: json
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -184,6 +184,7 @@ extensions: []
184
184
  extra_rdoc_files: []
185
185
  files:
186
186
  - ".gitignore"
187
+ - ".rspec"
187
188
  - CHANGELOG
188
189
  - Gemfile
189
190
  - LICENSE.txt
@@ -294,15 +295,25 @@ files:
294
295
  - lib/haveapi/views/version_sidebar/resource_nav.erb
295
296
  - shell.nix
296
297
  - spec/.rubocop.yml
298
+ - spec/action/authorize_spec.rb
297
299
  - spec/action/dsl_spec.rb
300
+ - spec/action/runtime_spec.rb
301
+ - spec/action_state_spec.rb
302
+ - spec/authentication/basic_spec.rb
303
+ - spec/authentication/oauth2_spec.rb
304
+ - spec/authentication/token_spec.rb
298
305
  - spec/authorization_spec.rb
299
306
  - spec/common_spec.rb
307
+ - spec/documentation/auth_filtering_spec.rb
300
308
  - spec/documentation_spec.rb
301
309
  - spec/envelope_spec.rb
310
+ - spec/extensions/action_exceptions_spec.rb
302
311
  - spec/hooks_spec.rb
312
+ - spec/model_adapters/active_record_spec.rb
303
313
  - spec/parameters/typed_spec.rb
304
314
  - spec/params_spec.rb
305
315
  - spec/resource_spec.rb
316
+ - spec/server/integration_spec.rb
306
317
  - spec/spec_helper.rb
307
318
  - spec/validators/acceptance_spec.rb
308
319
  - spec/validators/confirmation_spec.rb
@@ -313,6 +324,8 @@ files:
313
324
  - spec/validators/length_spec.rb
314
325
  - spec/validators/numericality_spec.rb
315
326
  - spec/validators/presence_spec.rb
327
+ - test_support/client_test_api.rb
328
+ - test_support/client_test_server.rb
316
329
  licenses:
317
330
  - MIT
318
331
  metadata: {}