p4_web_api 2014.2.0.pre2 → 2014.2.0.pre4

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 069a887b3fabfbb41e4014f0351e9ad79e699ee9
4
- data.tar.gz: 6fc582c57a30740878523f1265eade7bd6d24749
3
+ metadata.gz: 7928b3bca740696b9fb622b838ad6dfa0eb06857
4
+ data.tar.gz: abca39306fb28d24b7c986f19d11dcf041973904
5
5
  SHA512:
6
- metadata.gz: a055d9ca2f41db3f61c9f8d3969d8b5ef506759671437d157eec182af6eb8ff18ff0567704203b4c4755b2deff87806cc2867ac98e0f161de93c52245f3c24e2
7
- data.tar.gz: fa442bde271cf8387ac423ae2ba3fc9b8a88d38bd396478998990a8c1c74fb14f37065700acdf0e2276a89562143a25705038a8a85ba95bae5813de9382595da
6
+ metadata.gz: 4f8f49dec8c3fcb072354ae86c4ce6e3f5ea46ece96c4a5052e8a24a7cf43499332374149dd8792302c5152026d2d908f20bac6ce4e1306025342a78c3207c21
7
+ data.tar.gz: b1f84f8cc8e6c1d192b769ca9066d415571349d3f182b49c492605e5c58b3b37511f9e5565dfb8ee584105014d3d1f0f30cdd73a65048b16e84ec4c46bdd6b36
data/bin/p4_web_api CHANGED
@@ -1,4 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
- lib_path = File.expand_path('../../lib/p4_web_api.rb', __FILE__)
3
- require lib_path
2
+
3
+ ENV['RACK_ENV'] = 'production' unless ENV.key?('RACK_ENV')
4
+
5
+ lib_dir = File.expand_path('../../lib', __FILE__)
6
+ $LOAD_PATH.unshift(lib_dir)
7
+
8
+ require 'p4_web_api'
9
+
4
10
  P4WebAPI::App.run!
data/lib/p4_web_api.rb CHANGED
@@ -18,9 +18,10 @@ require 'sinatra/json'
18
18
  require 'sinatra/config_file'
19
19
  require 'rack/parser'
20
20
 
21
- require 'p4_web_api/version'
22
21
  require 'p4_web_api/auth'
22
+ require 'p4_web_api/helpers'
23
23
  require 'p4_web_api/p4_util'
24
+ require 'p4_web_api/version'
24
25
 
25
26
  # The P4WebAPI is our namespace for the web application.
26
27
  #
@@ -95,84 +96,6 @@ module P4WebAPI
95
96
 
96
97
  P4WebAPI::Auth.validate_token_dir(settings.token_path)
97
98
 
98
- helpers do
99
- # A block helper that uses the authentication credentials to create the p4
100
- # connection
101
- def open_p4(&block)
102
- options = {
103
- user: env['AUTH_CREDENTIALS'].first,
104
- password: P4Util.resolve_password(env, settings),
105
- host: P4Util.resolve_host(env, settings),
106
- port: P4Util.resolve_port(env, settings)
107
- }
108
-
109
- charset = P4Util.resolve_charset(env, settings)
110
- options[:charset] = charset if charset
111
-
112
- P4Util.open(options, &block)
113
- end
114
-
115
- # Block helper to open a p4 handle with a temporary client workspace.
116
- # The client workspace will map the series of depot path expressions
117
- # directly into the client workspace.
118
- #
119
- # The depot_paths are generally expected to be complete file path
120
- # expressions, e.g., '//depot/dir1/dir2/file'. They'll be mapped directly
121
- # to the temporary working area: '//client/depot/dir/1/dir2/file'
122
- #
123
- # The block handler here will be called with the arguments (p4, root),
124
- # where root is the temporary client's root directory.
125
- def open_p4_temp_client(depot_paths, &block)
126
- open_p4 do |p4|
127
- name = (0...8).map { (65 + rand(26)).chr }.join
128
- dir = init_temp_workspace_dir(name)
129
- init_temp_client(p4, name, dir, depot_paths)
130
-
131
- ex = nil
132
- begin
133
- block.call(p4, dir)
134
- rescue StandardError => e
135
- ex = e
136
- end
137
-
138
- delete_temp_client(p4, name, dir)
139
-
140
- fail ex unless ex.nil?
141
- end
142
- end
143
- end
144
-
145
- def init_temp_workspace_dir(name)
146
- dir = File.join(settings.workspace_folder, name)
147
- unless Dir.exist?(dir)
148
- FileUtils.mkpath(dir)
149
- FileUtils.chmod(0700, dir)
150
- end
151
- dir
152
- end
153
-
154
- def init_temp_client(p4, name, dir, depot_paths)
155
- spec = p4.fetch_client
156
- spec._root = dir
157
- spec._client = name
158
- spec._description = 'p4_web_api temp client'
159
-
160
- spec._view = depot_paths.map do |path|
161
- stripped = path.gsub(/^[\/]*/, '')
162
- "\"//#{stripped}\" \"//#{name}/#{stripped}\""
163
- end
164
-
165
- p4.save_client(spec)
166
- p4.client = name
167
-
168
- p4.run_sync('//...')
169
- end
170
-
171
- def delete_temp_client(p4, name, dir)
172
- p4.run_client('-d', '-f', name)
173
- FileUtils.rmtree(dir)
174
- end
175
-
176
99
  error do
177
100
  err = env['sinatra.error']
178
101
 
@@ -201,6 +124,16 @@ module P4WebAPI
201
124
  fail err
202
125
  end
203
126
 
127
+ not_found do
128
+ return {
129
+ # This is not an application error, so I'm using error code 0 to mean
130
+ # 'use HTTP status'
131
+ MessageCode: 0,
132
+ MessageSeverity: 3,
133
+ MessageText: 'Resource not found'
134
+ }.to_json
135
+ end
136
+
204
137
  def to_msg(message)
205
138
  {
206
139
  MessageCode: message.msgid,
@@ -209,6 +142,8 @@ module P4WebAPI
209
142
  }
210
143
  end
211
144
 
145
+ helpers Helpers
146
+
212
147
  # Will fetch the system offset based on the server date.
213
148
  #
214
149
  # If allow_env_p4_config is true, we don't cache. Each request could come
@@ -249,597 +184,6 @@ module P4WebAPI
249
184
  self.class.normalizers[spec_type].call(*args)
250
185
  end
251
186
 
252
- # Authentication methods
253
- # See also: https://confluence.perforce.com:8443/display/WS/Authentication+in+Web+Services
254
-
255
- # Creates a sign-in session for the user.
256
- #
257
- # This session returns a token that should be used as the password in basic
258
- # authentication for the user later.
259
- post '/v1/sessions' do
260
- login = params[:login]
261
- password = params[:password] # may be a p4 ticket
262
-
263
- token = nil
264
-
265
- options = {
266
- user: login,
267
- password: password,
268
- host: settings.p4['host'],
269
- port: settings.p4['port']
270
- }
271
- options[:charset] = settings.p4['charset'] if settings.p4.key?('charset')
272
-
273
- P4Util.open(options) do |p4|
274
- token = Auth.create_session(p4, password, settings)
275
- end
276
-
277
- # We signal a 401 when login/passwords are generally invalid. Since this
278
- # is an unauthenticated request, you shouldn't be able to tell if the
279
- # login doesn't exist or the password is incorrect.
280
- halt 401 unless token
281
-
282
- content_type 'text/plain'
283
- return token
284
- end
285
-
286
- # I'm not sure if we should ensure the token being deleted is the token
287
- # being authenticated against. That's not being checked for the time being.
288
- delete '/v1/sessions/:token' do |token|
289
- P4WebAPI::Auth.delete_session(token, settings)
290
- ''
291
- end
292
-
293
- # Generic 'do a perforce command' methods.
294
- #
295
- # These do not ensure true REST-ful style behavior, but are really just
296
- # "hey use HTTP to interact with Perforce".
297
-
298
- get '/v1/run' do
299
-
300
- cmd = params[:cmd]
301
-
302
- args = params.select { |key, _| key.start_with?('arg') }
303
- .map { |_, value| value }
304
-
305
- if settings.run_get_blacklist.include?(cmd)
306
- halt 403, { MessageCode: 15_360,
307
- MessageText: "#{cmd} not allowed in web api",
308
- MessageSeverity: :ERROR }.to_json
309
- end
310
-
311
- results = nil
312
- messages = nil
313
-
314
- open_p4 do |p4|
315
- results = p4.run(cmd, *args)
316
- messages = p4.messages
317
- end
318
-
319
- if messages && messages.length > 0
320
- messages.map { |m| to_msg(m) }.to_json
321
- elsif results
322
- results.to_json
323
- end
324
-
325
- end
326
-
327
- post '/v1/run' do
328
- cmd = params[:cmd]
329
-
330
- args = params.select { |key, _| key.start_with?('arg') }
331
- .map { |_, value| value }
332
-
333
- # Uses the same blacklist as GET which generally makes sense, since we'll
334
- # only really be concerned about client workspace usage on this server.
335
- if settings.run_get_blacklist.include?(cmd)
336
- halt 403, { MessageCode: 15_360,
337
- MessageText: "#{cmd} not allowed in web api",
338
- MessageSeverity: :ERROR }.to_json
339
- end
340
-
341
- messages = nil
342
-
343
- open_p4 do |p4|
344
- p4.input = filter_params(params)
345
- p4.run(cmd, args)
346
- messages = p4.messages
347
- end
348
-
349
- messages.map { |m| to_msg(m) }.to_json if messages
350
- end
351
-
352
- # Convenience method to the 'run' mechanism for typical browsing purposes.
353
- #
354
- # Unlike just standard run commands, this will select either the 'depots' or
355
- # 'files' and 'dirs' commands for a single directory level at a time.
356
-
357
- # Special depots only variant
358
- get '/v1/files' do
359
- results = nil
360
-
361
- open_p4 do |p4|
362
- results = p4.run_depots
363
- end
364
-
365
- normalize_depots(results) if settings.normalize_output
366
-
367
- results.to_json
368
- end
369
-
370
- # General browsing variation.
371
- get '/v1/files/*' do
372
- dirs = params[:splat].select { |x| !x.empty? }
373
-
374
- results = nil
375
-
376
- open_p4 do |p4|
377
- if dirs.empty?
378
- results = p4.run_depots
379
- normalize_depots(results) if settings.normalize_output
380
- else
381
- selector = '//' + dirs.join('/') + '/*'
382
- files_results = p4.run_files(selector)
383
- normalize_files(files_results) if settings.normalize_output
384
- dirs_results = p4.run_dirs(selector)
385
- normalize_dirs(dirs_results) if settings.normalize_output
386
- results = files_results + dirs_results
387
- end
388
- end
389
-
390
- results.to_json
391
- end
392
-
393
- # Convenience method to list changelist metadata with some common filtering
394
- # options.
395
- #
396
- # parameters:
397
- # - `max` = number
398
- # - `status` = pending|submitted|shelved
399
- # - `user` = [login]
400
- # - `files` = pattern
401
- get '/v1/changes' do
402
- max = params['max'] if params.key?('max')
403
- status = params['status'] if params.key?('status')
404
- user = params['user'] if params.key?('user')
405
- files = params['files'] if params.key?('files')
406
-
407
- results = nil
408
-
409
- open_p4 do |p4|
410
- args = ['changes']
411
-
412
- args.push('-m', max) if max
413
- args.push('-s', status) if status
414
- args.push('-u', user) if user
415
- args.push(files) if files
416
-
417
- results = p4.run(*args)
418
- end
419
-
420
- normalize_changes(results) if settings.normalize_output
421
-
422
- results.to_json
423
- end
424
-
425
- # Methods to manipulate the protections table. This blindly assumes you
426
- # indeed have superuser privileges.
427
-
428
- # Just list all protections
429
- get '/v1/protections' do
430
- protects = nil
431
- open_p4 do |p4|
432
- protects = p4.run_protect('-o').first
433
- end
434
-
435
- normalize_protections(results) if settings.normalize_output
436
-
437
- protects.to_json
438
- end
439
-
440
- # Update protections
441
- put '/v1/protections' do
442
- protects = {
443
- 'Protections' => params['Protections']
444
- }
445
-
446
- open_p4 do |p4|
447
- p4.input = protects
448
- p4.run_protect('-i')
449
- end
450
-
451
- ''
452
- end
453
-
454
- # Methods to adjust the triggers table.
455
-
456
- get '/v1/triggers' do
457
- triggers = nil
458
- open_p4 do |p4|
459
- triggers = p4.run_triggers('-o').first
460
- end
461
-
462
- normalize_triggers(results) if settings.normalize_output
463
-
464
- triggers.to_json
465
- end
466
-
467
- # Update protections
468
- put '/v1/triggers' do
469
- triggers = {
470
- 'Triggers' => params['Triggers']
471
- }
472
-
473
- open_p4 do |p4|
474
- p4.input = triggers
475
- p4.run_triggers('-i')
476
- end
477
-
478
- ''
479
- end
480
-
481
- # Generate CRUD methods for each 'spec' type
482
- #
483
-
484
- set(:is_spec) do |_x|
485
- condition do
486
- path_info = env['PATH_INFO']
487
-
488
- matches = %r{^/v1/(?<spec_type>\w+)}.match(path_info)
489
- if matches
490
- spec_type = matches[:spec_type]
491
- return (spec_type == 'branches' ||
492
- spec_type == 'clients' ||
493
- spec_type == 'depots' ||
494
- spec_type == 'groups' ||
495
- spec_type == 'jobs' ||
496
- spec_type == 'labels' ||
497
- # No protects here: the usage is pretty different from the other
498
- # spec mechanisms
499
- spec_type == 'servers'
500
- # No streams, triggers, or users here: it does not work like other
501
- # specs, so these method implementations don't quite work.
502
- )
503
- end
504
- false
505
- end
506
- end
507
-
508
- # Provide a generic collection accessor for each of the specs.
509
- get '/v1/:spec_type', is_spec: true do |spec_type|
510
- results = nil
511
-
512
- open_p4 do |p4|
513
- results = p4.run(spec_type)
514
- end
515
-
516
- send("normalize_#{spec_type}", results) if settings.normalize_output
517
- P4Util.collate_group_results(results) if settings.normalize_output &&
518
- spec_type == 'groups'
519
-
520
- results.to_json
521
- end
522
-
523
- # Provide a generic output accessor for each spec
524
- get '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
525
- results = nil
526
-
527
- open_p4 do |p4|
528
- results = p4.run(P4Util.singular(spec_type), '-o', id)
529
- end
530
-
531
- send("normalize_#{spec_type}", results) if settings.normalize_output
532
-
533
- results[0].to_json
534
- end
535
-
536
- # This is our generic "add" mechanism for each type.
537
- #
538
- # It's assumed that the client understands the requirements of each spec
539
- # type.
540
- post '/v1/:spec_type', is_spec: true do |spec_type|
541
- results = nil
542
-
543
- open_p4 do |p4|
544
- method_name = "save_#{P4Util.singular(spec_type)}".to_sym
545
- results = p4.send(method_name, params)
546
- end
547
-
548
- # In general, the params use the name of the spec as a capitalized
549
- # parameter in the singular form.
550
- if spec_type == 'servers'
551
- id_prop = 'ServerID'
552
- else
553
- id_prop = P4Util.singular(spec_type).capitalize
554
- end
555
- id = nil
556
- if results.is_a?(Array) &&
557
- results.length > 0 &&
558
- /Job .* saved/.match(results[0])
559
- # special "Job" variant to grab the ID out of the results output
560
- id = /Job (.*) saved/.match(results[0])[1]
561
- elsif params.key?(id_prop)
562
- id = params[id_prop]
563
- elsif params.key?(id_prop.to_sym)
564
- id = params[id_prop.to_sym]
565
- end
566
-
567
- if id
568
- redirect "/v1/#{spec_type}/#{id}"
569
- else
570
- halt 400, "Did not locate #{id_prop} in params"
571
- end
572
-
573
- end
574
-
575
- # An 'update' mechanism for each spec type.
576
- put '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
577
- open_p4 do |p4|
578
- singular = P4Util.singular(spec_type)
579
- spec = p4.run(singular, '-o', id)[0]
580
-
581
- spec = spec.merge(filter_params(params))
582
-
583
- method_name = "save_#{singular}".to_sym
584
- p4.send(method_name, spec)
585
- end
586
-
587
- ''
588
- end
589
-
590
- delete '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
591
- open_p4 do |p4|
592
- method_name = "delete_#{P4Util.singular(spec_type)}".to_sym
593
- p4.send(method_name, id)
594
- end
595
-
596
- ''
597
- end
598
-
599
- # List all users registered in the server
600
- get '/v1/users' do
601
- results = nil
602
-
603
- open_p4 do |p4|
604
- results = p4.run_users
605
- end
606
-
607
- normalize_users(results) if settings.normalize_output
608
-
609
- results.to_json
610
- end
611
-
612
- # Create a new user
613
- post '/v1/users' do
614
- open_p4 do |p4|
615
- p4.save_user(params, '-f')
616
- end
617
-
618
- login = params['User']
619
-
620
- redirect "/v1/users/#{login}"
621
- end
622
-
623
- get '/v1/users/:user' do |user|
624
- results = nil
625
-
626
- open_p4 do |p4|
627
- results = p4.run_user('-o', user)
628
- end
629
-
630
- normalize_users(results) if settings.normalize_output
631
-
632
- if results.empty?
633
- halt 404
634
- else
635
- results[0].to_json
636
- end
637
- end
638
-
639
- # 'Update' the user.
640
- put '/v1/users/:user' do |user|
641
- open_p4 do |p4|
642
- results = p4.run_user('-o', user)
643
-
644
- if results.empty?
645
- halt 404
646
- return
647
- end
648
-
649
- spec = results[0]
650
- spec = spec.merge(filter_params(params))
651
-
652
- spec['User'] = user unless spec.key?('User')
653
-
654
- p4.save_user(spec, '-f')
655
- end
656
-
657
- # Avoid 'user jdoe saved' message from going out
658
- ''
659
- end
660
-
661
- # Delete the user.
662
- delete '/v1/users/:user' do |user|
663
- open_p4 do |p4|
664
- p4.run_user('-f', '-d', user)
665
- end
666
-
667
- # If you're deleting yourself (ragequitting via API) remove thy session
668
- if user == env['AUTH_CREDENTIALS'].first
669
- token = env['AUTH_CREDENTIALS'].last
670
- P4WebAPI::Auth.delete_session(token, settings)
671
- end
672
- end
673
-
674
- # Stream manipulation
675
- #
676
- # Streams are a little different from other spec types, in that the singluar
677
- # 'stream' is identified by a depot-style path, as opposed to kind of a
678
- # name. (And since our paths start with two slashes, we ignore those).
679
-
680
- get '/v1/streams' do
681
- streams = nil
682
-
683
- open_p4 do |p4|
684
- streams = p4.run_streams
685
- end
686
-
687
- normalize_streams(streams) if settings.normalize_output
688
-
689
- streams.to_json
690
- end
691
-
692
- # Creates a new stream
693
- post '/v1/streams' do
694
- open_p4 do |p4|
695
- p4.save_stream(filter_params(params))
696
- end
697
-
698
- stream = params['Stream']
699
-
700
- sub_path = stream[2..-1]
701
-
702
- redirect "/v1/streams/#{sub_path}"
703
- end
704
-
705
- get '/v1/streams/*' do
706
- sub_path = params[:splat].join('')
707
-
708
- stream = "//#{sub_path}"
709
-
710
- results = nil
711
-
712
- open_p4 do |p4|
713
- results = p4.run_stream('-o', stream)
714
- end
715
-
716
- normalize_streams(results) if settings.normalize_output
717
-
718
- results[0].to_json
719
- end
720
-
721
- put '/v1/streams/*' do
722
- sub_path = params[:splat].join('')
723
-
724
- stream = "//#{sub_path}"
725
-
726
- open_p4 do |p4|
727
- spec = p4.run_stream('-o', stream)[0]
728
-
729
- spec = spec.merge(filter_params(params))
730
-
731
- p4.save_stream(spec, '-f')
732
- end
733
-
734
- ''
735
- end
736
-
737
- delete '/v1/streams/*' do
738
- sub_path = params[:splat].join('')
739
-
740
- stream = "//#{sub_path}"
741
-
742
- open_p4 do |p4|
743
- p4.run_stream('-d', stream)
744
- end
745
-
746
- ''
747
- end
748
-
749
- # The print method uses the print command underneath to output file content.
750
- #
751
- # Unlike most of the other methods, this does not output application/json.
752
- # If the type contains 'text' we set the Content-Type to 'text/plain',
753
- # otherwise it's 'application/octet-stream'.
754
- get '/v1/print/*' do
755
- path = params[:splat].join('')
756
- path = "//#{path}"
757
-
758
- results = nil
759
- open_p4 do |p4|
760
- results = p4.run_print(path)
761
- end
762
-
763
- file_type = results[0]
764
- content = results[1]
765
-
766
- if file_type['type'] =~ /text/
767
- content_type 'text/plain'
768
- else
769
- content_type 'application/octet-stream'
770
- end
771
-
772
- content
773
- end
774
-
775
- # The upload method adds new file revisions.
776
- #
777
- # This is a multipart method that accepts and array of files, plus,
778
- # a 'mappings' array to indicate the target depot path for each file.
779
- # This should be a JSON encoded array of strings.
780
- #
781
- # An optional 'description' attribute can describe the changes to be made.
782
- post '/v1/upload' do
783
- # if mappings[] and files[] do not have equivalent lengths, the error
784
- # might be kind of lame.
785
- mappings = params[:mappings]
786
- files = []
787
- mappings.each_index { |idx| files << params["file_#{idx}".to_sym] }
788
-
789
- open_p4_temp_client(mappings) do |p4, root|
790
- message = params[:description] || 'Uploaded files'
791
- change_id = init_changelist(p4, message)
792
-
793
- # Information used to determine if the new rev should be an add or edit
794
- files_results = p4.run_files(mappings)
795
-
796
- (0...mappings.length).each do |idx|
797
- type = existing_path?(files_results, mappings[idx]) ? 'edit' : 'add'
798
-
799
- if type == 'edit'
800
- mark_change('edit', p4, change_id, root, mappings[idx])
801
- save_content(root, mappings[idx], files[idx])
802
- else
803
- save_content(root, mappings[idx], files[idx])
804
- mark_change('add', p4, change_id, root, mappings[idx])
805
- end
806
- end
807
-
808
- p4.run_submit('-c', change_id)
809
- end
810
- end
811
-
812
- def init_changelist(p4, description)
813
- change_spec = p4.fetch_change
814
- change_spec._description = description
815
- results = p4.save_change(change_spec)
816
- results[0].gsub(/^Change (\d+) created./, '\1')
817
- end
818
-
819
- def save_content(root, depot_path, file)
820
- local_file = local_path(depot_path, root)
821
- dir = File.dirname(local_file)
822
- FileUtils.mkpath(dir) unless Dir.exist?(dir)
823
-
824
- IO.write(local_file, file[:tempfile].read)
825
- end
826
-
827
- def existing_path?(existing_results, depot_path)
828
- existing_results.any? do |result|
829
- result['depotFile'] == depot_path
830
- end
831
- end
832
-
833
- def mark_change(type, p4, change_id, root, depot_path)
834
- local_file = local_path(depot_path, root)
835
- p4.run(type, '-c', change_id, local_file)
836
- end
837
-
838
- def local_path(depot_path, root)
839
- stripped = depot_path.gsub(/^\/+/, '')
840
- File.join(root, stripped)
841
- end
842
-
843
187
  # Basically a "blacklist" of things we know the frameworks going to add to
844
188
  # the params array we don't want to pass on to the p4 command sets for spec
845
189
  # input
@@ -852,3 +196,16 @@ module P4WebAPI
852
196
  run! if app_file == $PROGRAM_NAME
853
197
  end
854
198
  end
199
+
200
+ # Reopen up the P4WebAPI::App class and add most of our method handling. This
201
+ # is done in lieu of having multiple Sinatra apps, so we can have the same
202
+ # configuration and error handling.
203
+ require 'p4_web_api/app/changes'
204
+ require 'p4_web_api/app/commands'
205
+ require 'p4_web_api/app/files'
206
+ require 'p4_web_api/app/protections'
207
+ require 'p4_web_api/app/sessions'
208
+ require 'p4_web_api/app/specs'
209
+ require 'p4_web_api/app/streams'
210
+ require 'p4_web_api/app/triggers'
211
+ require 'p4_web_api/app/users'