p4_web_api 2014.2.0.pre1

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 3a33dc12a8f5e6304cf6408d9aa711b206173a00
4
+ data.tar.gz: e9a9c2af51c18f19a22f8267cdade878f69b40e8
5
+ SHA512:
6
+ metadata.gz: 2864edc78108d670eaabd5e4b0e868a46950c3b2cffcb62c9920b0e09ba8c4827036359869b70ddddb4fbf1b2f4e4cc8cc7f4fa4627c6d7026897378488564f4
7
+ data.tar.gz: a1dc0543c17c29071686891eec720d25d0987aa4e5a4e9750b698cd57b8c06c474368720b94e30201de7773449a6fb5898bffb5d0b876ebe3ec18a583b88b4cc
data/bin/p4_web_api ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+ lib_path = File.expand_path('../../lib/p4_web_api.rb', __FILE__)
3
+ require lib_path
4
+ P4WebAPI::App.run!
data/lib/p4_web_api.rb ADDED
@@ -0,0 +1,700 @@
1
+ # Copyright (c) 2014 Perforce Software, Inc. All rights reserved.
2
+ # vim:ts=2:sw=2:et:si:ai:
3
+ # p4_web_api.rb
4
+ #
5
+ # Setup
6
+ # -----
7
+ #
8
+ # Avast, your webserver should map /p4 to the root of this application. This
9
+ # is easily done in rails with the following route:
10
+ #
11
+ # mount P4WebAPI, at: '/p4'
12
+ #
13
+ # You will need a p4_web_api.yml configuration file created as well, to indicate
14
+ # where security token data is stored, and where the Perforce server lies.
15
+
16
+ require 'sinatra/base'
17
+ require 'sinatra/json'
18
+ require 'sinatra/config_file'
19
+ require 'rack/parser'
20
+
21
+ require 'p4_web_api/version'
22
+ require 'p4_web_api/auth'
23
+ require 'p4_web_api/p4_util'
24
+
25
+ # The P4WebAPI is our namespace for the web application.
26
+ #
27
+ # See P4WebAPI::App for most of the implemented methods. In general each method
28
+ # maps to a P4Ruby call.
29
+ #
30
+ # The other modules under the P4WebAPI are generally helpers, like the
31
+ # Authentication middleware, or P4Util conventions around using P4Ruby.
32
+ module P4WebAPI
33
+ # This web application is mostly a lightweight way of connecting the tagged
34
+ # output via P4Ruby to JSON clients can consume relatively simply.
35
+ class App < Sinatra::Base
36
+ register Sinatra::ConfigFile
37
+
38
+ # Inject Rack::Parser into the middleware stack so that it
39
+ # automatically parses json bodies in post requests into the params
40
+ # array.
41
+ use Rack::Parser, content_types: {
42
+ 'application/json' => proc { |body| ::MultiJson.decode body }
43
+ }
44
+
45
+ use P4WebAPI::AuthMiddleware,
46
+ unauthenticated_paths: ['/v1/sessions'],
47
+ settings: settings
48
+
49
+ # Without this set, the return content type is always text/html
50
+ before do
51
+ content_type 'application/json'
52
+ end
53
+
54
+ configure do
55
+ set(:p4, 'host' => 'localhost', 'port' => '1666')
56
+ set(:token_path, '/tmp/p4_web_api/tokens')
57
+
58
+ set(:run_get_blacklist, %w(add change changelist clean copy cstat
59
+ delete diff edit flush have integ integrate
60
+ lock login logout move reconcile rename
61
+ reopen resolve resolved revert shelve submit
62
+ sync unlock unshelve where
63
+ ))
64
+
65
+ # To allow other rack middleware to decide P4_HOST and P4_PORT,
66
+ # allow_env_p4_config must be true, otherwise, we'll only use the standard
67
+ # rack configuration mechanism.
68
+ set(:allow_env_p4_config, false)
69
+
70
+ # This is odd, but with some of the project restructuring,
71
+ # this setting marked error.rb as the 'app_file'
72
+ set(:app_file, __FILE__)
73
+
74
+ # When set to true, we'll run most of the output through a 'normalization'
75
+ # set of rules. We don't do this for the run methods, but most fields
76
+ # should appear capitalized, and output dates should all show up in the
77
+ # standard epoch timestamp
78
+ set(:normalize_output, true)
79
+
80
+ set :raise_errors, :environment == :test
81
+ set :dump_errors, :environment == :development
82
+ set :show_exceptions, :environment == :development
83
+
84
+ enable :logging
85
+
86
+ config_path = if ENV.key?('P4_WEB_API_CONFIG')
87
+ ENV['P4_WEB_API_CONFIG']
88
+ else
89
+ 'p4_web_api.yml'
90
+ end
91
+
92
+ config_file config_path if File.exist?(config_path)
93
+ end
94
+
95
+ P4WebAPI::Auth.validate_token_dir(settings.token_path)
96
+
97
+ helpers do
98
+ # A block helper that uses the authentication credentials to create the p4
99
+ # connection
100
+ def open_p4(&block)
101
+ options = {
102
+ user: env['AUTH_CREDENTIALS'].first,
103
+ password: P4Util.resolve_password(env, settings),
104
+ host: P4Util.resolve_host(env, settings),
105
+ port: P4Util.resolve_port(env, settings)
106
+ }
107
+
108
+ charset = P4Util.resolve_charset(env, settings)
109
+ options[:charset] = charset if charset
110
+
111
+ P4Util.open(options, &block)
112
+ end
113
+
114
+ end
115
+
116
+ error do
117
+ err = env['sinatra.error']
118
+
119
+ if err.is_a?(P4Exception)
120
+ # Can happen when we're not passing a block to
121
+ # open_p4. Convert to a P4WebAPI error. This is not ideal
122
+ # as it always uses the same code, but then we have no idea
123
+ # what actually happened here.
124
+
125
+ err = P4WebAPI::P4Error.default_error(err.to_s)
126
+
127
+ # Fall through...
128
+ end
129
+
130
+ if err.is_a?(P4WebAPI::P4Error)
131
+ if err.message_code == 7480 || err.message_code == 7189
132
+ halt 401
133
+ else
134
+ return {
135
+ MessageCode: err.message_code,
136
+ MessageSeverity: err.message_severity,
137
+ MessageText: err.message_text
138
+ }.to_json
139
+ end
140
+ end
141
+ fail err
142
+ end
143
+
144
+ def to_msg(message)
145
+ {
146
+ MessageCode: message.msgid,
147
+ MessageSeverity: message.severity,
148
+ MessageText: message.to_s
149
+ }
150
+ end
151
+
152
+ # Will fetch the system offset based on the server date.
153
+ #
154
+ # If allow_env_p4_config is true, we don't cache. Each request could come
155
+ # in from a new server, and the different servers may have different
156
+ # offsets.
157
+ def offset
158
+ if settings.allow_env_p4_config
159
+ fetch_offset
160
+ else
161
+ @offset ||= fetch_offset
162
+ end
163
+ end
164
+
165
+ def fetch_offset
166
+ offset = nil
167
+ open_p4 do |p4|
168
+ results = p4.run_info
169
+ offset = P4Util.p4_date_offset(results[0]['serverDate'])
170
+ end
171
+ offset
172
+ end
173
+
174
+ @normalizers = {}
175
+
176
+ class << self
177
+ attr_accessor :normalizers
178
+ end
179
+
180
+ # It's assumed that these are typically used to find the different spec
181
+ # types within typical requests.
182
+ def method_missing(method, *args)
183
+ return unless method.to_s =~ /^normalize_(.*)/
184
+
185
+ spec_type = Regexp.last_match[1]
186
+ unless self.class.normalizers.key?(spec_type)
187
+ self.class.normalizers[spec_type] = P4Util.normalizer(spec_type, offset)
188
+ end
189
+ self.class.normalizers[spec_type].call(*args)
190
+ end
191
+
192
+ # Authentication methods
193
+ # See also: https://confluence.perforce.com:8443/display/WS/Authentication+in+Web+Services
194
+
195
+ # Creates a sign-in session for the user.
196
+ #
197
+ # This session returns a token that should be used as the password in basic
198
+ # authentication for the user later.
199
+ post '/v1/sessions' do
200
+ login = params[:login]
201
+ password = params[:password] # may be a p4 ticket
202
+
203
+ token = nil
204
+
205
+ options = {
206
+ user: login,
207
+ password: password,
208
+ host: settings.p4['host'],
209
+ port: settings.p4['port']
210
+ }
211
+ options[:charset] = settings.p4['charset'] if settings.p4.key?('charset')
212
+
213
+ P4Util.open(options) do |p4|
214
+ token = Auth.create_session(p4, password, settings)
215
+ end
216
+
217
+ # We signal a 401 when login/passwords are generally invalid. Since this
218
+ # is an unauthenticated request, you shouldn't be able to tell if the
219
+ # login doesn't exist or the password is incorrect.
220
+ halt 401 unless token
221
+
222
+ content_type 'text/plain'
223
+ return token
224
+ end
225
+
226
+ # I'm not sure if we should ensure the token being deleted is the token
227
+ # being authenticated against. That's not being checked for the time being.
228
+ delete '/v1/sessions/:token' do |token|
229
+ P4WebAPI::Auth.delete_session(token, settings)
230
+ ''
231
+ end
232
+
233
+ # Generic 'do a perforce command' methods.
234
+ #
235
+ # These do not ensure true REST-ful style behavior, but are really just
236
+ # "hey use HTTP to interact with Perforce".
237
+
238
+ get '/v1/run' do
239
+
240
+ cmd = params[:cmd]
241
+
242
+ args = params.select { |key, _| key.start_with?('arg') }
243
+ .map { |_, value| value }
244
+
245
+ if settings.run_get_blacklist.include?(cmd)
246
+ halt 403, { MessageCode: 15_360,
247
+ MessageText: "#{cmd} not allowed in web api",
248
+ MessageSeverity: :ERROR }.to_json
249
+ end
250
+
251
+ results = nil
252
+ messages = nil
253
+
254
+ open_p4 do |p4|
255
+ results = p4.run(cmd, *args)
256
+ messages = p4.messages
257
+ end
258
+
259
+ if messages && messages.length > 0
260
+ messages.map { |m| to_msg(m) }.to_json
261
+ elsif results
262
+ results.to_json
263
+ end
264
+
265
+ end
266
+
267
+ post '/v1/run' do
268
+ cmd = params[:cmd]
269
+
270
+ args = params.select { |key, _| key.start_with?('arg') }
271
+ .map { |_, value| value }
272
+
273
+ # Uses the same blacklist as GET which generally makes sense, since we'll
274
+ # only really be concerned about client workspace usage on this server.
275
+ if settings.run_get_blacklist.include?(cmd)
276
+ halt 403, { MessageCode: 15_360,
277
+ MessageText: "#{cmd} not allowed in web api",
278
+ MessageSeverity: :ERROR }.to_json
279
+ end
280
+
281
+ messages = nil
282
+
283
+ open_p4 do |p4|
284
+ p4.input = filter_params(params)
285
+ p4.run(cmd, args)
286
+ messages = p4.messages
287
+ end
288
+
289
+ messages.map { |m| to_msg(m) }.to_json if messages
290
+ end
291
+
292
+ # Convenience method to the 'run' mechanism for typical browsing purposes.
293
+ #
294
+ # Unlike just standard run commands, this will select either the 'depots' or
295
+ # 'files' and 'dirs' commands for a single directory level at a time.
296
+
297
+ # Special depots only variant
298
+ get '/v1/files' do
299
+ results = nil
300
+
301
+ open_p4 do |p4|
302
+ results = p4.run_depots
303
+ end
304
+
305
+ normalize_depots(results) if settings.normalize_output
306
+
307
+ results.to_json
308
+ end
309
+
310
+ # General browsing variation.
311
+ get '/v1/files/*' do
312
+ dirs = params[:splat].select { |x| !x.empty? }
313
+
314
+ results = nil
315
+
316
+ open_p4 do |p4|
317
+ if dirs.empty?
318
+ results = p4.run_depots
319
+ normalize_depots(results) if settings.normalize_output
320
+ else
321
+ selector = '//' + dirs.join('/') + '/*'
322
+ files_results = p4.run_files(selector)
323
+ normalize_files(files_results) if settings.normalize_output
324
+ dirs_results = p4.run_dirs(selector)
325
+ normalize_dirs(dirs_results) if settings.normalize_output
326
+ results = files_results + dirs_results
327
+ end
328
+ end
329
+
330
+ results.to_json
331
+ end
332
+
333
+ # Convenience method to list changelist metadata with some common filtering
334
+ # options.
335
+ #
336
+ # parameters:
337
+ # - `max` = number
338
+ # - `status` = pending|submitted|shelved
339
+ # - `user` = [login]
340
+ # - `files` = pattern
341
+ get '/v1/changes' do
342
+ max = params['max'] if params.key?('max')
343
+ status = params['status'] if params.key?('status')
344
+ user = params['user'] if params.key?('user')
345
+ files = params['files'] if params.key?('files')
346
+
347
+ results = nil
348
+
349
+ open_p4 do |p4|
350
+ args = ['changes']
351
+
352
+ args.push('-m', max) if max
353
+ args.push('-s', status) if status
354
+ args.push('-u', user) if user
355
+ args.push(files) if files
356
+
357
+ results = p4.run(*args)
358
+ end
359
+
360
+ normalize_changes(results) if settings.normalize_output
361
+
362
+ results.to_json
363
+ end
364
+
365
+ # Methods to manipulate the protections table. This blindly assumes you
366
+ # indeed have superuser privileges.
367
+
368
+ # Just list all protections
369
+ get '/v1/protections' do
370
+ protects = nil
371
+ open_p4 do |p4|
372
+ protects = p4.run_protect('-o').first
373
+ end
374
+
375
+ normalize_protections(results) if settings.normalize_output
376
+
377
+ protects.to_json
378
+ end
379
+
380
+ # Update protections
381
+ put '/v1/protections' do
382
+ protects = {
383
+ 'Protections' => params['Protections']
384
+ }
385
+
386
+ open_p4 do |p4|
387
+ p4.input = protects
388
+ p4.run_protect('-i')
389
+ end
390
+
391
+ ''
392
+ end
393
+
394
+ # Methods to adjust the triggers table.
395
+
396
+ get '/v1/triggers' do
397
+ triggers = nil
398
+ open_p4 do |p4|
399
+ triggers = p4.run_triggers('-o').first
400
+ end
401
+
402
+ normalize_triggers(results) if settings.normalize_output
403
+
404
+ triggers.to_json
405
+ end
406
+
407
+ # Update protections
408
+ put '/v1/triggers' do
409
+ triggers = {
410
+ 'Triggers' => params['Triggers']
411
+ }
412
+
413
+ open_p4 do |p4|
414
+ p4.input = triggers
415
+ p4.run_triggers('-i')
416
+ end
417
+
418
+ ''
419
+ end
420
+
421
+ # Generate CRUD methods for each 'spec' type
422
+ #
423
+
424
+ set(:is_spec) do |_x|
425
+ condition do
426
+ path_info = env['PATH_INFO']
427
+
428
+ matches = %r{^/v1/(?<spec_type>\w+)}.match(path_info)
429
+ if matches
430
+ spec_type = matches[:spec_type]
431
+ return (spec_type == 'branches' ||
432
+ spec_type == 'clients' ||
433
+ spec_type == 'depots' ||
434
+ spec_type == 'groups' ||
435
+ spec_type == 'jobs' ||
436
+ spec_type == 'labels' ||
437
+ # No protects here: the usage is pretty different from the other
438
+ # spec mechanisms
439
+ spec_type == 'servers'
440
+ # No streams, triggers, or users here: it does not work like other
441
+ # specs, so these method implementations don't quite work.
442
+ )
443
+ end
444
+ false
445
+ end
446
+ end
447
+
448
+ # Provide a generic collection accessor for each of the specs.
449
+ get '/v1/:spec_type', is_spec: true do |spec_type|
450
+ results = nil
451
+
452
+ open_p4 do |p4|
453
+ results = p4.run(spec_type)
454
+ end
455
+
456
+ send("normalize_#{spec_type}", results) if settings.normalize_output
457
+ P4Util.collate_group_results(results) if settings.normalize_output &&
458
+ spec_type == 'groups'
459
+
460
+ results.to_json
461
+ end
462
+
463
+ # Provide a generic output accessor for each spec
464
+ get '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
465
+ results = nil
466
+
467
+ open_p4 do |p4|
468
+ results = p4.run(P4Util.singular(spec_type), '-o', id)
469
+ end
470
+
471
+ send("normalize_#{spec_type}", results) if settings.normalize_output
472
+
473
+ results[0].to_json
474
+ end
475
+
476
+ # This is our generic "add" mechanism for each type.
477
+ #
478
+ # It's assumed that the client understands the requirements of each spec
479
+ # type.
480
+ post '/v1/:spec_type', is_spec: true do |spec_type|
481
+ results = nil
482
+
483
+ open_p4 do |p4|
484
+ method_name = "save_#{P4Util.singular(spec_type)}".to_sym
485
+ results = p4.send(method_name, params)
486
+ end
487
+
488
+ # In general, the params use the name of the spec as a capitalized
489
+ # parameter in the singular form.
490
+ if spec_type == 'servers'
491
+ id_prop = 'ServerID'
492
+ else
493
+ id_prop = P4Util.singular(spec_type).capitalize
494
+ end
495
+ id = nil
496
+ if results.is_a?(Array) &&
497
+ results.length > 0 &&
498
+ /Job .* saved/.match(results[0])
499
+ # special "Job" variant to grab the ID out of the results output
500
+ id = /Job (.*) saved/.match(results[0])[1]
501
+ elsif params.key?(id_prop)
502
+ id = params[id_prop]
503
+ elsif params.key?(id_prop.to_sym)
504
+ id = params[id_prop.to_sym]
505
+ end
506
+
507
+ if id
508
+ redirect "/v1/#{spec_type}/#{id}"
509
+ else
510
+ halt 400, "Did not locate #{id_prop} in params"
511
+ end
512
+
513
+ end
514
+
515
+ # An 'update' mechanism for each spec type.
516
+ put '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
517
+ open_p4 do |p4|
518
+ singular = P4Util.singular(spec_type)
519
+ spec = p4.run(singular, '-o', id)[0]
520
+
521
+ spec = spec.merge(filter_params(params))
522
+
523
+ method_name = "save_#{singular}".to_sym
524
+ p4.send(method_name, spec)
525
+ end
526
+
527
+ ''
528
+ end
529
+
530
+ delete '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
531
+ open_p4 do |p4|
532
+ method_name = "delete_#{P4Util.singular(spec_type)}".to_sym
533
+ p4.send(method_name, id)
534
+ end
535
+
536
+ ''
537
+ end
538
+
539
+ # List all users registered in the server
540
+ get '/v1/users' do
541
+ results = nil
542
+
543
+ open_p4 do |p4|
544
+ results = p4.run_users
545
+ end
546
+
547
+ normalize_users(results) if settings.normalize_output
548
+
549
+ results.to_json
550
+ end
551
+
552
+ # Create a new user
553
+ post '/v1/users' do
554
+ open_p4 do |p4|
555
+ p4.save_user(params, '-f')
556
+ end
557
+
558
+ login = params['User']
559
+
560
+ redirect "/v1/users/#{login}"
561
+ end
562
+
563
+ get '/v1/users/:user' do |user|
564
+ results = nil
565
+
566
+ open_p4 do |p4|
567
+ results = p4.run_user('-o', user)
568
+ end
569
+
570
+ normalize_users(results) if settings.normalize_output
571
+
572
+ if results.empty?
573
+ halt 404
574
+ else
575
+ results[0].to_json
576
+ end
577
+ end
578
+
579
+ # 'Update' the user.
580
+ put '/v1/users/:user' do |user|
581
+ open_p4 do |p4|
582
+ results = p4.run_user('-o', user)
583
+
584
+ if results.empty?
585
+ halt 404
586
+ return
587
+ end
588
+
589
+ spec = results[0]
590
+ spec = spec.merge(filter_params(params))
591
+
592
+ spec['User'] = user unless spec.key?('User')
593
+
594
+ p4.save_user(spec, '-f')
595
+ end
596
+
597
+ # Avoid 'user jdoe saved' message from going out
598
+ ''
599
+ end
600
+
601
+ # Delete the user.
602
+ delete '/v1/users/:user' do |user|
603
+ open_p4 do |p4|
604
+ p4.run_user('-f', '-d', user)
605
+ end
606
+
607
+ # If you're deleting yourself (ragequitting via API) remove thy session
608
+ if user == env['AUTH_CREDENTIALS'].first
609
+ token = env['AUTH_CREDENTIALS'].last
610
+ P4WebAPI::Auth.delete_session(token, settings)
611
+ end
612
+ end
613
+
614
+ # Stream manipulation
615
+ #
616
+ # Streams are a little different from other spec types, in that the singluar
617
+ # 'stream' is identified by a depot-style path, as opposed to kind of a
618
+ # name. (And since our paths start with two slashes, we ignore those).
619
+
620
+ get '/v1/streams' do
621
+ streams = nil
622
+
623
+ open_p4 do |p4|
624
+ streams = p4.run_streams
625
+ end
626
+
627
+ normalize_streams(streams) if settings.normalize_output
628
+
629
+ streams.to_json
630
+ end
631
+
632
+ # Creates a new stream
633
+ post '/v1/streams' do
634
+ open_p4 do |p4|
635
+ p4.save_stream(filter_params(params))
636
+ end
637
+
638
+ stream = params['Stream']
639
+
640
+ sub_path = stream[2..-1]
641
+
642
+ redirect "/v1/streams/#{sub_path}"
643
+ end
644
+
645
+ get '/v1/streams/*' do
646
+ sub_path = params[:splat].join('')
647
+
648
+ stream = "//#{sub_path}"
649
+
650
+ results = nil
651
+
652
+ open_p4 do |p4|
653
+ results = p4.run_stream('-o', stream)
654
+ end
655
+
656
+ normalize_streams(results) if settings.normalize_output
657
+
658
+ results[0].to_json
659
+ end
660
+
661
+ put '/v1/streams/*' do
662
+ sub_path = params[:splat].join('')
663
+
664
+ stream = "//#{sub_path}"
665
+
666
+ open_p4 do |p4|
667
+ spec = p4.run_stream('-o', stream)[0]
668
+
669
+ spec = spec.merge(filter_params(params))
670
+
671
+ p4.save_stream(spec, '-f')
672
+ end
673
+
674
+ ''
675
+ end
676
+
677
+ delete '/v1/streams/*' do
678
+ sub_path = params[:splat].join('')
679
+
680
+ stream = "//#{sub_path}"
681
+
682
+ open_p4 do |p4|
683
+ p4.run_stream('-d', stream)
684
+ end
685
+
686
+ ''
687
+ end
688
+
689
+ # Basically a "blacklist" of things we know the frameworks going to add to
690
+ # the params array we don't want to pass on to the p4 command sets for spec
691
+ # input
692
+ def filter_params(params)
693
+ params.select do |k, _v|
694
+ k != 'spec_type' && k != 'id' && k != 'splat' && k != 'captures'
695
+ end
696
+ end
697
+
698
+ run! if app_file == $PROGRAM_NAME
699
+ end
700
+ end