p4_web_api 2014.2.0.pre2 → 2014.2.0.pre4
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 +4 -4
- data/bin/p4_web_api +8 -2
- data/lib/p4_web_api.rb +27 -670
- data/lib/p4_web_api/app/changes.rb +73 -0
- data/lib/p4_web_api/app/commands.rb +58 -0
- data/lib/p4_web_api/app/files.rb +166 -0
- data/lib/p4_web_api/app/protections.rb +33 -0
- data/lib/p4_web_api/app/sessions.rb +45 -0
- data/lib/p4_web_api/app/specs.rb +114 -0
- data/lib/p4_web_api/app/streams.rb +76 -0
- data/lib/p4_web_api/app/triggers.rb +31 -0
- data/lib/p4_web_api/app/users.rb +78 -0
- data/lib/p4_web_api/auth.rb +3 -3
- data/lib/p4_web_api/change_helper.rb +149 -0
- data/lib/p4_web_api/helpers.rb +84 -0
- data/lib/p4_web_api/p4_util.rb +90 -1
- data/lib/p4_web_api/version.rb +1 -1
- metadata +13 -2
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'p4_web_api/change_helper'
|
2
|
+
|
3
|
+
module P4WebAPI
|
4
|
+
# Add methods related to changelist resources.
|
5
|
+
class App < Sinatra::Base
|
6
|
+
# Convenience method to list changelist metadata with some common filtering
|
7
|
+
# options.
|
8
|
+
#
|
9
|
+
# parameters:
|
10
|
+
# - `max` = number
|
11
|
+
# - `status` = pending|submitted|shelved
|
12
|
+
# - `user` = perforce login
|
13
|
+
# - `files` = pattern
|
14
|
+
get '/v1/changes' do
|
15
|
+
max = params['max'] if params.key?('max')
|
16
|
+
status = params['status'] if params.key?('status')
|
17
|
+
user = params['user'] if params.key?('user')
|
18
|
+
files = params['files'] if params.key?('files')
|
19
|
+
|
20
|
+
results = nil
|
21
|
+
|
22
|
+
open_p4 do |p4|
|
23
|
+
args = ['changes']
|
24
|
+
|
25
|
+
args.push('-m', max) if max
|
26
|
+
args.push('-s', status) if status
|
27
|
+
args.push('-u', user) if user
|
28
|
+
args.push(files) if files
|
29
|
+
|
30
|
+
results = p4.run(*args)
|
31
|
+
end
|
32
|
+
|
33
|
+
normalize_changes(results) if settings.normalize_output
|
34
|
+
|
35
|
+
results.to_json
|
36
|
+
end
|
37
|
+
|
38
|
+
# Create a new changelist with edits to multiple files.
|
39
|
+
post '/v1/changes' do
|
40
|
+
description = params['Description'] || 'Edited files'
|
41
|
+
files = params['Files']
|
42
|
+
|
43
|
+
depot_paths = files.map { |f| f['DepotFile'] }
|
44
|
+
|
45
|
+
open_p4_temp_client(depot_paths) do |p4, client_root, client_name|
|
46
|
+
helper = ChangeHelper.new(p4: p4,
|
47
|
+
client_root: client_root,
|
48
|
+
client_name: client_name,
|
49
|
+
description: description,
|
50
|
+
files: files)
|
51
|
+
helper.call
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
# Uses describe to produce the list of opened files regardless of client.
|
56
|
+
#
|
57
|
+
# There is a little bit of an open question regarding performance. The
|
58
|
+
# p4ruby usage here does *not* generate diffs of changes, which the base
|
59
|
+
# command line seems to want to do. If the output accumulates a lot of RAM
|
60
|
+
# for large diffs, we could be in trouble.
|
61
|
+
get '/v1/changes/:change' do |change|
|
62
|
+
results = nil
|
63
|
+
|
64
|
+
open_p4 do |p4|
|
65
|
+
results = p4.run_describe('-S', change)
|
66
|
+
end
|
67
|
+
|
68
|
+
normalize_describe(results) if settings.normalize_output
|
69
|
+
|
70
|
+
results.to_json
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
|
3
|
+
module P4WebAPI
|
4
|
+
# Methods for executing generic Perforce commands.
|
5
|
+
#
|
6
|
+
# These do not actually map to any particular 'resource', but are just a
|
7
|
+
# bucket for random things you can try out.
|
8
|
+
#
|
9
|
+
# thj: I would prefer to remove these methods, and replace them with
|
10
|
+
# 'resources' we would define for people.
|
11
|
+
class App < Sinatra::Base
|
12
|
+
get '/v1/commands/:cmd' do |cmd|
|
13
|
+
args = params.select { |k, _| k.start_with?('arg') }.map { |_, v| v }
|
14
|
+
|
15
|
+
if settings.run_get_blacklist.include?(cmd)
|
16
|
+
halt 403, { MessageCode: 15_360,
|
17
|
+
MessageText: "#{cmd} not allowed in web api",
|
18
|
+
MessageSeverity: :ERROR }.to_json
|
19
|
+
end
|
20
|
+
|
21
|
+
results = nil
|
22
|
+
messages = nil
|
23
|
+
|
24
|
+
open_p4 do |p4|
|
25
|
+
results = p4.run(cmd, *args)
|
26
|
+
messages = p4.messages
|
27
|
+
end
|
28
|
+
|
29
|
+
if messages && messages.length > 0
|
30
|
+
messages.map { |m| to_msg(m) }.to_json
|
31
|
+
elsif results
|
32
|
+
results.to_json
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
post '/v1/commands/:cmd' do |cmd|
|
37
|
+
args = params.select { |key, _| key.start_with?('arg') }.map { |_, x| x }
|
38
|
+
|
39
|
+
# Uses the same blacklist as GET which generally makes sense, since we'll
|
40
|
+
# only really be concerned about client workspace usage on this server.
|
41
|
+
if settings.run_get_blacklist.include?(cmd)
|
42
|
+
halt 403, { MessageCode: 15_360,
|
43
|
+
MessageText: "#{cmd} not allowed in web api",
|
44
|
+
MessageSeverity: :ERROR }.to_json
|
45
|
+
end
|
46
|
+
|
47
|
+
messages = nil
|
48
|
+
|
49
|
+
open_p4 do |p4|
|
50
|
+
p4.input = filter_params(params)
|
51
|
+
p4.run(cmd, args)
|
52
|
+
messages = p4.messages
|
53
|
+
end
|
54
|
+
|
55
|
+
messages.map { |m| to_msg(m) }.to_json if messages
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,166 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'p4_web_api/change_helper'
|
3
|
+
require 'p4_web_api/p4_util'
|
4
|
+
require 'sinatra/base'
|
5
|
+
|
6
|
+
module P4WebAPI
|
7
|
+
# Add 'file' methods
|
8
|
+
#
|
9
|
+
# 'files' are actually a combination of path metadata. There are three
|
10
|
+
# major kinds of file resources: depots, dirs, and files. The true file
|
11
|
+
# resources can be a combination of details, along with the file content.
|
12
|
+
class App < Sinatra::Base
|
13
|
+
# Special depots only variant to match no path
|
14
|
+
get '/v1/files' do
|
15
|
+
results = nil
|
16
|
+
|
17
|
+
open_p4 do |p4|
|
18
|
+
results = p4.run_depots
|
19
|
+
end
|
20
|
+
|
21
|
+
normalize_depots(results) if settings.normalize_output
|
22
|
+
|
23
|
+
results.to_json
|
24
|
+
end
|
25
|
+
|
26
|
+
# General browsing variation.
|
27
|
+
#
|
28
|
+
# Since we want this to be able to fetch file content in the case you
|
29
|
+
# specify a file, versus a directory listing, we actually execute the
|
30
|
+
# 'p4 files' command on the file. If we get a single result back, we then
|
31
|
+
# add the base64'd content. If the file does not exist, we treat it like
|
32
|
+
# a directory request. Thus, you'll never really get a 404, just an empty
|
33
|
+
# array.
|
34
|
+
get '/v1/files/*' do
|
35
|
+
dirs = params[:splat].select { |x| !x.empty? }
|
36
|
+
|
37
|
+
results = nil
|
38
|
+
|
39
|
+
open_p4 do |p4|
|
40
|
+
if dirs.empty?
|
41
|
+
results = p4.run_depots
|
42
|
+
normalize_depots(results) if settings.normalize_output
|
43
|
+
else
|
44
|
+
file_selector = '//' + dirs.join('/')
|
45
|
+
files_results = nil
|
46
|
+
p4.at_exception_level(P4::RAISE_NONE) do
|
47
|
+
files_results = p4.run_files(file_selector)
|
48
|
+
end
|
49
|
+
files_results = [] unless files_results
|
50
|
+
|
51
|
+
if files_results.length == 1 &&
|
52
|
+
files_results.first.key?('depotFile') &&
|
53
|
+
files_results.first['depotFile'] == file_selector
|
54
|
+
# Treat request like a single file GET
|
55
|
+
normalize_files(files_results) if settings.normalize_output
|
56
|
+
results = files_results[0]
|
57
|
+
|
58
|
+
print_results = p4.run_print(file_selector)
|
59
|
+
|
60
|
+
results['Content'] = Base64.encode64(print_results[1])
|
61
|
+
|
62
|
+
else
|
63
|
+
|
64
|
+
# Treat request like a directory list
|
65
|
+
selector = '//' + dirs.join('/') + '/*'
|
66
|
+
|
67
|
+
files_results = p4.run_files('-e', selector)
|
68
|
+
normalize_files(files_results) if settings.normalize_output
|
69
|
+
|
70
|
+
dirs_results = p4.run_dirs(selector)
|
71
|
+
normalize_dirs(dirs_results) if settings.normalize_output
|
72
|
+
|
73
|
+
results = files_results + dirs_results
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
results.to_json
|
79
|
+
end
|
80
|
+
|
81
|
+
# File upload mechanism
|
82
|
+
#
|
83
|
+
# This allows for multi-file patching based on particular directory level.
|
84
|
+
# Because sinatra doesn't really support JSON array bodies, this must be
|
85
|
+
# specified via a 'Files' parameter. If that parameter exists, we consider
|
86
|
+
# this to be a directory upload.
|
87
|
+
#
|
88
|
+
# If this is a directory upload, we expect the following parameters on each
|
89
|
+
# array object:
|
90
|
+
#
|
91
|
+
# - 'Content' - The base64 content
|
92
|
+
# - 'DepotFile' - the *relative* path from the main splat
|
93
|
+
#
|
94
|
+
# Otherwise, we mostly just care about the 'Content'
|
95
|
+
# fields for single file uploads.
|
96
|
+
#
|
97
|
+
# In both cases, a 'Description' field can be used to indicate a release
|
98
|
+
# message.
|
99
|
+
patch '/v1/files/*' do
|
100
|
+
path_parts = params[:splat].select { |x| !x.empty? }
|
101
|
+
description = params['Description'] || 'Uploaded files'
|
102
|
+
is_dir = params.key?('Files')
|
103
|
+
|
104
|
+
files = nil
|
105
|
+
if is_dir
|
106
|
+
# TODO: 'clean' the directory path, avoiding refs like '...'
|
107
|
+
dir_root = "//#{path_parts.join('/')}"
|
108
|
+
|
109
|
+
files = params['Files'].map do |f|
|
110
|
+
{
|
111
|
+
'DepotFile' => "#{dir_root}/#{f['DepotFile']}",
|
112
|
+
'Content' => f['Content']
|
113
|
+
}
|
114
|
+
end
|
115
|
+
else
|
116
|
+
# TODO: 'sanitize' this file path
|
117
|
+
files = [
|
118
|
+
{
|
119
|
+
'DepotFile' => "//#{path_parts.join('/')}",
|
120
|
+
'Content' => params[:Content]
|
121
|
+
}
|
122
|
+
]
|
123
|
+
end
|
124
|
+
files.each { |f| f['Action'] = 'upload' }
|
125
|
+
|
126
|
+
depot_paths = files.map { |f| f['DepotFile'] }
|
127
|
+
|
128
|
+
open_p4_temp_client(depot_paths) do |p4, client_root, client_name|
|
129
|
+
helper = ChangeHelper.new(p4: p4,
|
130
|
+
client_root: client_root,
|
131
|
+
client_name: client_name,
|
132
|
+
description: description,
|
133
|
+
files: files)
|
134
|
+
helper.call
|
135
|
+
end
|
136
|
+
|
137
|
+
''
|
138
|
+
end
|
139
|
+
|
140
|
+
# Delete a single file.
|
141
|
+
delete '/v1/files/*' do
|
142
|
+
description = params['Description'] || 'Deleting file'
|
143
|
+
path_parts = params[:splat].select { |x| !x.empty? }
|
144
|
+
|
145
|
+
# TODO: 'sanitize' this file path?
|
146
|
+
file_path = "//#{path_parts.join('/')}"
|
147
|
+
|
148
|
+
open_p4_temp_client([file_path]) do |p4|
|
149
|
+
change_id = P4Util.init_changelist(p4, description)
|
150
|
+
begin
|
151
|
+
p4.run_delete('-c', change_id, file_path)
|
152
|
+
p4.run_submit('-c', change_id)
|
153
|
+
rescue StandardError => ex
|
154
|
+
p4.at_exeception_level(P4::RAISE_NONE) do
|
155
|
+
p4.run_change('-d', '-f', change_id)
|
156
|
+
if P4Util.error?(p4)
|
157
|
+
puts "possible issues deleting change #{change_id}: " \
|
158
|
+
"#{p4.messages}"
|
159
|
+
end
|
160
|
+
end
|
161
|
+
raise ex
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module P4WebAPI
|
2
|
+
# Methods to manipulate protections table.
|
3
|
+
#
|
4
|
+
# The 'protections' resource in our system is the complete list, so you don't
|
5
|
+
# fetch or manipulate any single 'protection'. It's all or nothing.
|
6
|
+
class App < Sinatra::Base
|
7
|
+
# Just list all protections
|
8
|
+
get '/v1/protections' do
|
9
|
+
protects = nil
|
10
|
+
open_p4 do |p4|
|
11
|
+
protects = p4.run_protect('-o').first
|
12
|
+
end
|
13
|
+
|
14
|
+
normalize_protections(results) if settings.normalize_output
|
15
|
+
|
16
|
+
protects.to_json
|
17
|
+
end
|
18
|
+
|
19
|
+
# Update protections
|
20
|
+
put '/v1/protections' do
|
21
|
+
protects = {
|
22
|
+
'Protections' => params['Protections']
|
23
|
+
}
|
24
|
+
|
25
|
+
open_p4 do |p4|
|
26
|
+
p4.input = protects
|
27
|
+
p4.run_protect('-i')
|
28
|
+
end
|
29
|
+
|
30
|
+
''
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'p4_web_api/auth'
|
2
|
+
|
3
|
+
module P4WebAPI
|
4
|
+
# Authentication methods
|
5
|
+
# See also: https://confluence.perforce.com:8443/display/WS/Authentication+in+Web+Services
|
6
|
+
class App < Sinatra::Base
|
7
|
+
# Creates a sign-in session for the user.
|
8
|
+
#
|
9
|
+
# This session returns a token that should be used as the password in basic
|
10
|
+
# authentication for the user later.
|
11
|
+
post '/v1/sessions' do
|
12
|
+
user = params[:user]
|
13
|
+
password = params[:password] # may be a p4 ticket
|
14
|
+
|
15
|
+
token = nil
|
16
|
+
|
17
|
+
options = {
|
18
|
+
user: user,
|
19
|
+
password: password,
|
20
|
+
host: settings.p4['host'],
|
21
|
+
port: settings.p4['port']
|
22
|
+
}
|
23
|
+
options[:charset] = settings.p4['charset'] if settings.p4.key?('charset')
|
24
|
+
|
25
|
+
P4Util.open(options) do |p4|
|
26
|
+
token = Auth.create_session(p4, password, settings)
|
27
|
+
end
|
28
|
+
|
29
|
+
# We signal a 401 when login/passwords are generally invalid. Since this
|
30
|
+
# is an unauthenticated request, you shouldn't be able to tell if the
|
31
|
+
# login doesn't exist or the password is incorrect.
|
32
|
+
halt 401 unless token
|
33
|
+
|
34
|
+
content_type 'text/plain'
|
35
|
+
return token
|
36
|
+
end
|
37
|
+
|
38
|
+
# I'm not sure if we should ensure the token being deleted is the token
|
39
|
+
# being authenticated against. That's not being checked for the time being.
|
40
|
+
delete '/v1/sessions/:token' do |token|
|
41
|
+
P4WebAPI::Auth.delete_session(token, settings)
|
42
|
+
''
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,114 @@
|
|
1
|
+
module P4WebAPI
|
2
|
+
# Generic CRUD behavior for most of our spec types.
|
3
|
+
class App < Sinatra::Base
|
4
|
+
set(:is_spec) do |_x|
|
5
|
+
condition do
|
6
|
+
path_info = env['PATH_INFO']
|
7
|
+
|
8
|
+
matches = %r{^/v1/(?<spec_type>\w+)}.match(path_info)
|
9
|
+
if matches
|
10
|
+
spec_type = matches[:spec_type]
|
11
|
+
return (spec_type == 'branches' ||
|
12
|
+
spec_type == 'clients' ||
|
13
|
+
spec_type == 'depots' ||
|
14
|
+
spec_type == 'groups' ||
|
15
|
+
spec_type == 'jobs' ||
|
16
|
+
spec_type == 'labels' ||
|
17
|
+
spec_type == 'servers'
|
18
|
+
)
|
19
|
+
end
|
20
|
+
false
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
# Provide a generic collection accessor for each of the specs.
|
25
|
+
get '/v1/:spec_type', is_spec: true do |spec_type|
|
26
|
+
results = nil
|
27
|
+
|
28
|
+
open_p4 do |p4|
|
29
|
+
results = p4.run(spec_type)
|
30
|
+
end
|
31
|
+
|
32
|
+
send("normalize_#{spec_type}", results) if settings.normalize_output
|
33
|
+
P4Util.collate_group_results(results) if settings.normalize_output &&
|
34
|
+
spec_type == 'groups'
|
35
|
+
|
36
|
+
results.to_json
|
37
|
+
end
|
38
|
+
|
39
|
+
# Provide a generic output accessor for each spec
|
40
|
+
get '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
|
41
|
+
results = nil
|
42
|
+
|
43
|
+
open_p4 do |p4|
|
44
|
+
results = p4.run(P4Util.singular(spec_type), '-o', id)
|
45
|
+
end
|
46
|
+
|
47
|
+
send("normalize_#{spec_type}", results) if settings.normalize_output
|
48
|
+
|
49
|
+
results[0].to_json
|
50
|
+
end
|
51
|
+
|
52
|
+
# This is our generic "add" mechanism for each type.
|
53
|
+
#
|
54
|
+
# It's assumed that the client understands the requirements of each spec
|
55
|
+
# type.
|
56
|
+
post '/v1/:spec_type', is_spec: true do |spec_type|
|
57
|
+
results = nil
|
58
|
+
|
59
|
+
open_p4 do |p4|
|
60
|
+
method_name = "save_#{P4Util.singular(spec_type)}".to_sym
|
61
|
+
results = p4.send(method_name, params)
|
62
|
+
end
|
63
|
+
|
64
|
+
# In general, the params use the name of the spec as a capitalized
|
65
|
+
# parameter in the singular form.
|
66
|
+
if spec_type == 'servers'
|
67
|
+
id_prop = 'ServerID'
|
68
|
+
else
|
69
|
+
id_prop = P4Util.singular(spec_type).capitalize
|
70
|
+
end
|
71
|
+
id = nil
|
72
|
+
if results.is_a?(Array) &&
|
73
|
+
results.length > 0 &&
|
74
|
+
/Job .* saved/.match(results[0])
|
75
|
+
# special "Job" variant to grab the ID out of the results output
|
76
|
+
id = /Job (.*) saved/.match(results[0])[1]
|
77
|
+
elsif params.key?(id_prop)
|
78
|
+
id = params[id_prop]
|
79
|
+
elsif params.key?(id_prop.to_sym)
|
80
|
+
id = params[id_prop.to_sym]
|
81
|
+
end
|
82
|
+
|
83
|
+
if id
|
84
|
+
redirect "/v1/#{spec_type}/#{id}"
|
85
|
+
else
|
86
|
+
halt 400, "Did not locate #{id_prop} in params"
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# An 'update' mechanism for each spec type.
|
91
|
+
patch '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
|
92
|
+
open_p4 do |p4|
|
93
|
+
singular = P4Util.singular(spec_type)
|
94
|
+
spec = p4.run(singular, '-o', id)[0]
|
95
|
+
|
96
|
+
spec = spec.merge(filter_params(params))
|
97
|
+
|
98
|
+
method_name = "save_#{singular}".to_sym
|
99
|
+
p4.send(method_name, spec)
|
100
|
+
end
|
101
|
+
|
102
|
+
''
|
103
|
+
end
|
104
|
+
|
105
|
+
delete '/v1/:spec_type/:id', is_spec: true do |spec_type, id|
|
106
|
+
open_p4 do |p4|
|
107
|
+
method_name = "delete_#{P4Util.singular(spec_type)}".to_sym
|
108
|
+
p4.send(method_name, id)
|
109
|
+
end
|
110
|
+
|
111
|
+
''
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|