p4_web_api 2014.2.0.pre1

Sign up to get free protection for your applications and to get access to all the features.
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