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.
@@ -0,0 +1,268 @@
1
+ # Copyright (c) 2014 Perforce Software, Inc. All rights reserved.
2
+ # vim:ts=2:sw=2:et:si:ai:
3
+
4
+ require 'fileutils'
5
+ require 'json'
6
+ require 'pathname'
7
+ require 'securerandom'
8
+
9
+ require_relative 'p4_util'
10
+
11
+ module P4WebAPI
12
+ # Sinatra Middleware component to take the HTTP Basic authentication request
13
+ # and pass that along to the internal P4WebAPI::Auth class methods
14
+ class AuthMiddleware
15
+ def initialize(app, options = {})
16
+ @app = app
17
+ @unauthenticated_paths = []
18
+ if options[:unauthenticated_paths]
19
+ @unauthenticated_paths = options[:unauthenticated_paths]
20
+ end
21
+ @settings = options[:settings]
22
+ end
23
+
24
+ def call(env)
25
+ return @app.call(env) if unauthenticated_path?(env)
26
+
27
+ auth = Rack::Auth::Basic::Request.new(env)
28
+ if auth.provided? &&
29
+ auth.basic? &&
30
+ P4WebAPI::Auth.credentials_valid?(auth.credentials, @settings)
31
+ env['AUTH_CREDENTIALS'] = auth.credentials
32
+ return @app.call(env)
33
+ end
34
+
35
+ unauthenticated_error
36
+ end
37
+
38
+ def unauthenticated_path?(env)
39
+ @unauthenticated_paths.include?(env['PATH_INFO'])
40
+ end
41
+
42
+ def unauthenticated_error
43
+ [
44
+ 403,
45
+ { 'Content-Type' => 'text/plain',
46
+ 'Content-Length' => '0',
47
+ 'WWW-Authenticate' => 'Basic realm="Perforce Web API"' },
48
+ []
49
+ ]
50
+ end
51
+ end
52
+
53
+ # "Session" handling that basically uses Perforce on the backend and stores
54
+ # the p4 ticket in a GUID file. We then pass the GUID around 'publicly' since
55
+ # that's really only relevant to this one instance. We can then fetch the
56
+ # p4 ticket and pass that to other services internally without "exposing" it.
57
+ module Auth
58
+ # Double checks that really only the current user can write to the token
59
+ # directory. If it doesn't exist, we just print warnings to stdout.
60
+ #
61
+ # These checks are only intended to work on Linux and OS X.
62
+ def self.validate_token_dir(token_dir)
63
+ warn_if_tmp_dir(token_dir)
64
+
65
+ return unless File.exist?(token_dir)
66
+
67
+ if !File.owned?(token_dir)
68
+ puts "The current user does not own security token dir: #{token_dir}"
69
+ else
70
+ mode = File.stat(token_dir).mode
71
+ warn_illegal_privileges(mode, token_dir)
72
+ end
73
+ end
74
+
75
+ # If the login/password combination is valid, we'll create a new session and
76
+ # return the session ID (which is just a GUID).
77
+ #
78
+ # This assumes that the p4 connection already has the user name set.
79
+ #
80
+ # If the password is a ticket, that'll be stored as is. If it's a password
81
+ # (currently determined if it's not a 32 character alphanumeric string)
82
+ # we'll create a ticket and store that.
83
+ #
84
+ # Settings is the Sinatra application configuration.
85
+ def self.create_session(p4, password, settings)
86
+ ticket = nil
87
+
88
+ if P4Util.p4_ticket?(password)
89
+ ticket = password
90
+ else
91
+ ticket = ticket_from_login(p4)
92
+ end
93
+
94
+ return if ticket.nil?
95
+
96
+ results = p4.run_user('-o')
97
+
98
+ save_token(user_info(p4, results, ticket), settings)
99
+ end
100
+
101
+ # Saves the information stored in the user_info hash to a new randomly
102
+ # generated ticket file, and returns that new ticket file's name.
103
+ #
104
+ # user_info should have the following values:
105
+ # * login
106
+ # * email
107
+ # * full_name
108
+ # * ticket
109
+ def self.save_token(user_info, settings)
110
+ unless File.directory?(settings.token_path)
111
+ Pathname.new(settings.token_path).mkpath
112
+ FileUtils.chmod(0700, settings.token_path)
113
+ end
114
+
115
+ token = SecureRandom.uuid
116
+ token_path = Pathname.new(settings.token_path) + token
117
+
118
+ File.open(token_path, 'w', 0600) do |file|
119
+ file.write(JSON.generate(user_info))
120
+ end
121
+
122
+ token
123
+ end
124
+
125
+ # Removes the session file
126
+ #
127
+ # Assumes blindly that we're supposed to be able to do this. The caller
128
+ # should ensure that the token is deletable.
129
+ def self.delete_session(token, settings)
130
+ token_path = Pathname.new(settings.token_path) + token
131
+ File.delete(token_path) if File.exist?(token_path)
132
+ end
133
+
134
+ # Takes an array with [login, password], and returns true if the password
135
+ # is a legal token for that login, or looks like a p4 ticket.
136
+ def self.credentials_valid?(credentials, settings)
137
+ login = credentials.first
138
+ password = credentials.last
139
+ # Can't really determine much at this point, just return true. If we're
140
+ # a token, it's a UUID and we'll learn more about that later.
141
+ return true unless P4Util.uuid?(password)
142
+
143
+ user_info = read_token(password, settings)
144
+ if user_info
145
+ return user_info['login'] == login
146
+ else
147
+ false
148
+ end
149
+ end
150
+
151
+ def self.read_token(token, settings)
152
+ token_path = Pathname.new(settings.token_path) + token
153
+ if File.exist?(token_path)
154
+ File.open(token_path, 'r') do |file|
155
+ return JSON.parse(file.read)
156
+ end
157
+ end
158
+ nil
159
+ end
160
+
161
+ private
162
+
163
+ def self.warn_if_tmp_dir(token_dir)
164
+ if token_dir.start_with?('/tmp')
165
+ puts "Your token directory is using the default '/tmp' location, "\
166
+ 'please reconfigure to a reliable location'
167
+ end
168
+ end
169
+
170
+ def self.warn_illegal_privileges(mode, token_dir)
171
+ warn_unless_user_rwx(mode, token_dir)
172
+ warn_no_group_write(mode, token_dir)
173
+ warn_no_group_read(mode, token_dir)
174
+ warn_no_group_execute(mode, token_dir)
175
+ warn_no_other_write(mode, token_dir)
176
+ warn_no_other_read(mode, token_dir)
177
+ warn_no_other_execute(mode, token_dir)
178
+ end
179
+
180
+ # Check owner read/write/execute - should all be there
181
+ def self.warn_unless_user_rwx(mode, token_dir)
182
+ unless (mode & 0400 == 0400) &&
183
+ (mode & 0200 == 0200) &&
184
+ (mode & 0100 == 0100)
185
+ puts "The token_path '#{token_dir}' should allow the owner read, "\
186
+ 'write and execute privileges'
187
+ end
188
+ end
189
+
190
+ def self.warn_no_group_write(mode, token_dir)
191
+ if (mode & 0040 == 0040)
192
+ puts "The token_path '#{token_dir}' should not have group write access"
193
+ end
194
+ end
195
+
196
+ def self.warn_no_group_read(mode, token_dir)
197
+ if (mode & 0020 == 0020)
198
+ puts "The token_path '#{token_dir}' should not have group read access"
199
+ end
200
+ end
201
+
202
+ def self.warn_no_group_execute(mode, token_dir)
203
+ if (mode & 0010 == 0010)
204
+ puts "The token_path '#{token_dir}' should not have group "\
205
+ 'execute access'
206
+ end
207
+ end
208
+
209
+ def self.warn_no_other_write(mode, token_dir)
210
+ if (mode & 0004 == 0004)
211
+ puts "The token_path '#{token_dir}' should not have other write access"
212
+ end
213
+ end
214
+
215
+ def self.warn_no_other_read(mode, token_dir)
216
+ if (mode & 0002 == 0002)
217
+ puts "The token_path '#{token_dir}' should not have other read access"
218
+ end
219
+ end
220
+
221
+ def self.warn_no_other_execute(mode, token_dir)
222
+ if (mode & 0001 == 0001)
223
+ puts "The token_path '#{token_dir}' should not have other "\
224
+ 'execute access'
225
+ end
226
+ end
227
+
228
+ # We want special error handling here to return 4xx codes instead of 5xx
229
+ # in the face of an invalid password. Temporarily drop to lowest
230
+ # exception level, and just return nil when login doesn't work.
231
+ def self.ticket_from_login(p4)
232
+ results = nil
233
+ p4.at_exception_level(P4::RAISE_NONE) do
234
+ results = p4.run_login('-p')
235
+ end
236
+
237
+ auth_ok = raise_unless_auth_error(p4)
238
+
239
+ if !auth_ok
240
+ nil
241
+ else
242
+ p4.password = results[0]
243
+ end
244
+ end
245
+
246
+ def self.raise_unless_auth_error(p4)
247
+ if P4Util.error?(p4)
248
+ msg = p4.messages[0]
249
+ if msg.msgid == 7205 || # invalid user
250
+ msg.msgid == 7206 # invalid password
251
+ return false
252
+ else
253
+ P4Util.raise_error(p4)
254
+ end
255
+ end
256
+ true
257
+ end
258
+
259
+ def self.user_info(p4, results, ticket)
260
+ {
261
+ login: p4.user,
262
+ email: results[0]['Email'],
263
+ full_name: results[0]['FullName'],
264
+ ticket: ticket
265
+ }
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,36 @@
1
+ # vim:ts=2:sw=2:et:si:ai:
2
+ # The API sends back messages via the p4.messages attribute if there was an
3
+ # error with the last command. We will often convert that to this exception,
4
+ # which will be formatted into JSON.
5
+ require 'P4'
6
+
7
+ module P4WebAPI
8
+ # Any error we get from the Perforce server is generally encapsulated in
9
+ # a P4Error, which allows us to get some basic diagnostic information from
10
+ # the Perforce server out to the user.
11
+ class P4Error < RuntimeError
12
+ # All error codes must be greater than this number.
13
+ ERROR_CODE_BASE = 15_360
14
+
15
+ attr_accessor :message_code, :message_severity, :message_text
16
+
17
+ # Class method to create a valid P4Error object from a simple
18
+ # string. Used when we get errors for which no P4::Message object
19
+ # is available.
20
+ def self.default_error(message_text)
21
+ P4Error.new(ERROR_CODE_BASE,
22
+ P4::E_FAILED,
23
+ message_text)
24
+ end
25
+
26
+ def initialize(code, severity, text)
27
+ @message_code = code
28
+ @message_severity = severity
29
+ @message_text = text
30
+ end
31
+
32
+ def to_s
33
+ @message_text
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,417 @@
1
+ # Copyright (c) 2014 Perforce Software, Inc. All rights reserved.
2
+ #
3
+ # vim:ts=2:sw=2:et:si:ai:
4
+ #
5
+ # p4_util.rb
6
+ #
7
+ #
8
+
9
+ require 'date'
10
+ require 'ostruct'
11
+ require 'P4'
12
+
13
+ require_relative 'p4_error'
14
+
15
+ module P4WebAPI
16
+ # Namespace for p4ruby conventions used in the P4 Web API.
17
+ module P4Util
18
+ PROPERTIES = [
19
+ :password, :port, :user, :api_level, :charset, :client,
20
+ :host, :handler, :maxlocktime, :maxresults, :maxscanrows, :prog,
21
+ :ticketfile
22
+ ]
23
+
24
+ # Creates your p4 connection using some common forms.
25
+ #
26
+ # If you call open with a block, this will call connect before your block
27
+ # executes, and disconnect afterwards.
28
+ #
29
+ # If you do not call open with a block, it is up to the caller to connect
30
+ # and disconnect. (It's assumed you are calling w/o a block because you want
31
+ # to manage when the connection actually needs to happen.)
32
+ def self.open(options = {})
33
+ p4 = create_p4(options)
34
+
35
+ # Again, if we're calling using the block, we'll connect and disconnect.
36
+ # Otherwise, just return the created p4 object.
37
+ if block_given?
38
+ begin
39
+ p4.connect
40
+ yield p4
41
+ rescue P4Exception
42
+ raise make_p4_error(p4)
43
+ end
44
+ else
45
+ return p4
46
+ end
47
+ ensure
48
+ p4.disconnect if block_given? && p4 && p4.connected?
49
+ end
50
+
51
+ # Check for P4 errors
52
+ def self.error?(p4)
53
+ !p4.errors.empty?
54
+ end
55
+
56
+ # Raise an exception if necessary on a P4 Error
57
+ def self.raise_error(p4)
58
+ err = p4.messages.find { |m| m.severity > 2 }
59
+ fail P4WebAPI::P4Error.new(err.msgid, err.severity, err.to_s)
60
+ end
61
+
62
+ # Returns true if the string looks like a Perforce authentication ticket.
63
+ def self.p4_ticket?(str)
64
+ /^[a-zA-Z0-9]{32,}$/.match(str) != nil
65
+ end
66
+
67
+ # We only count uuids that were returned via SecureRandom.uuid used to
68
+ # generate internal security tokens.
69
+ def self.uuid?(str)
70
+ /^[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}$/
71
+ .match(str)
72
+ end
73
+
74
+ def self.singular(plural)
75
+ matches = {
76
+ branches: 'branch',
77
+ clients: 'client',
78
+ depots: 'depot',
79
+ groups: 'group',
80
+ jobs: 'job',
81
+ labels: 'label',
82
+ protects: 'protect',
83
+ servers: 'server',
84
+ streams: 'stream',
85
+ triggers: 'trigger',
86
+ users: 'user'
87
+ }
88
+ matches[plural.to_sym]
89
+ end
90
+
91
+ # Data 'normalization'
92
+ #
93
+ # A very annoying aspect of our tagged output is that it often is *slightly*
94
+ # different between 'plural' and spec forms. This logic is made available
95
+ # as an option in this API
96
+
97
+ BRANCHES_MAP = {
98
+ 'branch' => 'Branch'
99
+ }
100
+ BRANCHES_DATES = %w(Update Access)
101
+
102
+ CHANGES_MAP = {
103
+ 'path' => 'Path',
104
+ 'change' => 'Change',
105
+ 'time' => 'Date',
106
+ 'client' => 'Client',
107
+ 'user' => 'User',
108
+ 'status' => 'Status',
109
+ 'type' => 'Type',
110
+ 'changeType' => 'Type',
111
+ 'desc' => 'Description'
112
+ }
113
+ DESCRIBES_MAP = CHANGES_MAP
114
+ CHANGES_DATES = ['Date']
115
+
116
+ CLIENTS_MAP = {
117
+ 'client' => 'Client'
118
+ }
119
+ CLIENTS_DATES = %w(Update Access)
120
+
121
+ DEPOTS_MAP = {
122
+ 'name' => 'Depot',
123
+ 'time' => 'Date',
124
+ 'type' => 'Type',
125
+ 'map' => 'Map',
126
+ 'desc' => 'Description'
127
+ }
128
+ DEPOTS_DATES = ['Date']
129
+
130
+ DIRS_MAP = {
131
+ 'dir' => 'Dir'
132
+ }
133
+
134
+ FILES_MAP = {
135
+ 'depotFile' => 'DepotFile',
136
+ 'rev' => 'Revision',
137
+ 'change' => 'Change',
138
+ 'action' => 'Action',
139
+ 'type' => 'Type',
140
+ 'time' => 'Date'
141
+ }
142
+ FILES_DATES = %w(Date)
143
+
144
+ GROUPS_MAP = {
145
+ 'group' => 'Group',
146
+ 'maxResults' => 'MaxResults',
147
+ 'maxScanRows' => 'MaxScanRows',
148
+ 'maxLockTime' => 'MaxLockTime',
149
+ 'timeout' => 'Timeout',
150
+ 'desc' => 'Description',
151
+ 'user' => 'User',
152
+ 'isSubGroup' => 'IsSubGroup',
153
+ 'isOwner' => 'IsOwner',
154
+ 'isUser' => 'IsUser',
155
+ 'passTimeout' => 'PasswordTimeout'
156
+ }
157
+
158
+ JOBS_MAP = {}
159
+ JOBS_DATES = %w(Date)
160
+
161
+ LABELS_MAP = {
162
+ 'label' => 'Label'
163
+ }
164
+ LABELS_DATES = %w(Update Access)
165
+
166
+ # This isn't supported but kept here for reference. We *might* support this.
167
+ # OPENED_MAP = {
168
+ # 'change' => 'Change',
169
+ # 'client' => 'Client',
170
+ # 'user' => 'User'
171
+ # }
172
+
173
+ STREAMS_MAP = {
174
+ 'desc' => 'Description'
175
+ }
176
+ STREAMS_DATES = %w(Update Access)
177
+
178
+ USERS_MAP = {
179
+ 'passwordChange' => 'PasswordChange'
180
+ }
181
+ USERS_DATES = %w(Update Access PasswordChange)
182
+
183
+ # For each 'spec type' returns a function that will ensure that:
184
+ # - case is consistent between different return calls, prefer 'spec' types
185
+ # - dates are returned
186
+ #
187
+ # Parameters:
188
+ # - spec_type is the 'plural' form of spec class, e.g., 'users', 'clients'
189
+ # - offset is the current server offset, retrieve this via 'p4 info' command
190
+ #
191
+ # rubocop:disable Metrics/CyclomaticComplexity
192
+ def self.normalizer(spec_type, offset)
193
+ case spec_type
194
+ when 'branches'
195
+ make_normalizer(BRANCHES_MAP, offset, BRANCHES_DATES)
196
+ when 'changes'
197
+ make_normalizer(CHANGES_MAP, offset, CHANGES_DATES)
198
+ when 'clients'
199
+ make_normalizer(CLIENTS_MAP, offset, CLIENTS_DATES)
200
+ when 'depots'
201
+ make_normalizer(DEPOTS_MAP, offset, DEPOTS_DATES)
202
+ when 'dirs'
203
+ make_normalizer(DIRS_MAP, offset)
204
+ when 'files'
205
+ make_normalizer(FILES_MAP, offset, FILES_DATES)
206
+ when 'groups'
207
+ date_and_case = make_normalizer(GROUPS_MAP, offset)
208
+ lambda do |results|
209
+ results = date_and_case.call(results)
210
+ P4Util.replace_unset_with_0(results)
211
+ results
212
+ end
213
+ when 'jobs'
214
+ make_normalizer(JOBS_MAP, offset, JOBS_DATES)
215
+ when 'labels'
216
+ make_normalizer(LABELS_MAP, offset, LABELS_DATES)
217
+ when 'streams'
218
+ make_normalizer(STREAMS_MAP, offset, STREAMS_DATES)
219
+ when 'users'
220
+ make_normalizer(USERS_MAP, offset, USERS_DATES)
221
+ else
222
+ # By default, do no translation
223
+ return ->(x) { x }
224
+ end
225
+ end
226
+
227
+ def self.make_normalizer(field_map, offset, date_fields = nil)
228
+ lambda do |results|
229
+ return unless results
230
+
231
+ # We need to ignore any instances of P4::Spec since that will 'validate'
232
+ # fields on it's accessor methods
233
+ results.map! do |result|
234
+ if result.class <= P4::Spec
235
+ spec = result
236
+ result = Hash.new
237
+ result.merge!(spec)
238
+ else
239
+ result
240
+ end
241
+ end
242
+
243
+ results.each do |result|
244
+ update_fields(field_map, result)
245
+ update_dates(date_fields, result, offset)
246
+ end
247
+ results
248
+ end
249
+ end
250
+
251
+ # These are 'single values' of properties of Group objects we use while
252
+ # collating them.
253
+ GROUP_PROPERTIES = %w(
254
+ Group MaxResults MaxScanRows MaxLockTime Timeout PasswordTimeout
255
+ )
256
+
257
+ # Will apply the normalizer to set up a consistent field naming and date
258
+ # format, but then 'collates' the output into a single list of 'groups'.
259
+ #
260
+ # The groups output is kind of funny, because it basically lists users,
261
+ # with group access fields. It's very non-obvious, and in the end, most
262
+ # clients will need to run logic like this anyway.
263
+ def self.collate_group_results(results)
264
+ collated = results.group_by { |x| x['Group'] }
265
+ updated = collated.map do |_key, items|
266
+ # The first item sets most of the values, we then figure out array
267
+ # subvalues
268
+
269
+ group = {}
270
+ GROUP_PROPERTIES.each do |p|
271
+ group[p] = items.first[p] if items.first.key?(p)
272
+ end
273
+
274
+ group['Users'] =
275
+ items.find_all { |x| x['IsUser'] == '1' }.map { |x| x['User'] }
276
+
277
+ group['Subgroups'] =
278
+ items.find_all { |x| x['IsSubGroup'] == '1' }.map { |x| x['User'] }
279
+
280
+ group['Owners'] =
281
+ items.find_all { |x| x['IsOwner'] == '1' }.map { |x| x['User'] }
282
+
283
+ group
284
+ end
285
+
286
+ results.replace(updated)
287
+ end
288
+
289
+ def self.replace_unset_with_0(results)
290
+ results.each do |r|
291
+ r.each_key { |k| r[k] = '0' if r[k] == 'unset' }
292
+ end
293
+ end
294
+
295
+ # Returns true if .to_i will actually convert this string to an integer
296
+ def self.i?(str)
297
+ (str =~ /\A[-+]?\d+\z/)
298
+ end
299
+
300
+ # Returns true if we can parse the string as a date
301
+ def self.date_str?(str)
302
+ Date.parse(str) rescue false
303
+ end
304
+
305
+ # In general we get dates without any offset information, which has to be
306
+ # retrieved via the serverDate field from 'p4 info'
307
+ def self.p4_date_to_i(offset, p4date)
308
+ DateTime.parse("#{p4date} #{offset}").to_time.to_i
309
+ end
310
+
311
+ def self.p4_date_offset(str)
312
+ DateTime.parse(str).zone
313
+ end
314
+
315
+ def self.resolve_host(env, settings)
316
+ if env.key?('P4_HOST') && settings.allow_env_p4_config
317
+ env['P4_HOST']
318
+ else
319
+ settings.p4['host']
320
+ end
321
+ end
322
+
323
+ def self.resolve_port(env, settings)
324
+ if env.key?('P4_PORT') && settings.allow_env_p4_config
325
+ env['P4_PORT']
326
+ else
327
+ settings.p4['port']
328
+ end
329
+ end
330
+
331
+ def self.resolve_charset(env, settings)
332
+ if env.key?('P4_CHARSET') && settings.allow_env_p4_config
333
+ env['P4_CHARSET']
334
+ else
335
+ settings.p4['charset'] if settings.p4.key?('charset')
336
+ end
337
+ end
338
+
339
+ def self.resolve_password(env, settings)
340
+ password = env['AUTH_CREDENTIALS'].last
341
+ if !P4Util.uuid?(password)
342
+ password
343
+ else
344
+ P4WebAPI::Auth.read_token(password, settings)['ticket']
345
+ end
346
+ end
347
+
348
+ private
349
+
350
+ def self.create_p4(options)
351
+ p4 = P4.new
352
+
353
+ init_p4(p4)
354
+
355
+ PROPERTIES.each do|key|
356
+ p4.method(key.to_s + '=').call(options[key]) if options[key]
357
+ end
358
+
359
+ # From the WTF department, if you think you don't want a charset set,
360
+ # make damn sure P4Ruby isn't going to get confused.
361
+ p4.charset = nil if p4.charset == 'none'
362
+
363
+ # Make P4Ruby only raise exceptions if there are errors. Warnings
364
+ # (such as 'no such file(s)' don't get the same treatment.
365
+ p4.exception_level = P4::RAISE_ERRORS
366
+
367
+ p4
368
+ end
369
+
370
+ # Before we do anything, clear out any environment variables that may have
371
+ # leaked in from your environment
372
+ #
373
+ # Some of our HTTP APIs (e.g, Git Fusion) *do* utilize environment setups
374
+ # for a system user, that may be installed alongside this API. We want this
375
+ # system to always explicitly state which configuration to use. (So, it's
376
+ # not just a concern of our development environments.)
377
+ def self.init_p4(p4)
378
+ p4.client = 'invalid'
379
+ p4.port = ''
380
+ p4.host = ''
381
+ p4.password = ''
382
+ end
383
+
384
+ def self.make_p4_error(p4)
385
+ if p4.messages && p4.messages.first
386
+ m = p4.messages.first
387
+ P4WebAPI::P4Error.new(m.msgid, m.severity, m.to_s)
388
+ else
389
+ P4WebAPI::P4Error.default_error($ERROR_INFO.to_s)
390
+ end
391
+ end
392
+
393
+ def self.update_fields(field_map, result)
394
+ field_map.each_key do |key|
395
+ next unless result.key?(key)
396
+ val = result[key]
397
+ result.delete(key)
398
+ result[field_map[key]] = val
399
+ end
400
+ end
401
+
402
+ # Ensures that all dates looks like epoch second (integer) times
403
+ def self.update_dates(date_fields, result, offset)
404
+ if date_fields
405
+ date_fields.each do |date_key|
406
+ next unless result.key?(date_key)
407
+ val = result[date_key]
408
+ if i?(val)
409
+ result[date_key] = val.to_i
410
+ elsif date_str?(val)
411
+ result[date_key] = p4_date_to_i(offset, val)
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
417
+ end