p4_web_api 2014.2.0.pre1

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