domainnameapi 1.0.0

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,1747 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module DomainNameApi
6
+ # Raised inside the client when the gateway answers with an error status or
7
+ # the request fails in transport. Never escapes a public method: every
8
+ # method catches it and returns the error envelope. `code` is the HTTP
9
+ # status (or the curl-style transport error number).
10
+ class ApiError < StandardError
11
+ attr_reader :code
12
+
13
+ def initialize(message, code = 0)
14
+ super(message)
15
+ @code = code
16
+ end
17
+ end
18
+
19
+ # Port of php-dna's DNARest: one method per gateway operation, each
20
+ # returning php-dna's result structure (Hashes with String keys).
21
+ #
22
+ # Use DomainNameApi::Client, the public facade; Rest is its engine.
23
+ class Rest
24
+ include Telemetry
25
+
26
+ URL_PROD = 'https://api.domainresellerapi.com/api/v1'
27
+ URL_OTE = 'https://ote.domainresellerapi.com/api/v1'
28
+ USER_AGENT = "DomainNameAPI-Ruby/#{VERSION}"
29
+
30
+ RESULT_OK = 'OK'
31
+ RESULT_ERROR = 'ERROR'
32
+ RESULT_SUCCESS = 'SUCCESS'
33
+
34
+ DEFAULT_TIMEOUT = 30
35
+ DEFAULT_CONNECT_TIMEOUT = 10
36
+ DEFAULT_REASON = 'Owner request'
37
+ DEFAULT_NAMESERVERS = ['ns1.domainnameapi.com', 'ns2.domainnameapi.com'].freeze
38
+
39
+ # Minimum gap between two /domains/zones calls, in seconds.
40
+ ZONE_MIN_INTERVAL = 1.0
41
+
42
+ DEFAULT_ERRORS = {
43
+ 'DOMAIN_DETAILS' => ['Invalid domain details! Details format is not valid',
44
+ 'The provided domain details are not in the expected format'],
45
+ 'CREDENTIALS' => ['Invalid reseller ID or API key', 'The provided API credentials are invalid'],
46
+ 'DOMAIN_LIST' => ['The domain list is invalid or contains multiple entries!',
47
+ 'The domain list response format is incorrect or unexpected'],
48
+ 'TLD_LIST' => ['TLD info is not a valid array or more than one TLD info has returned!',
49
+ 'The TLD list response is not in the expected format'],
50
+ 'RESPONSE' => ['Invalid response received from server! Response is empty.', 'The API response is empty or null'],
51
+ 'RESPONSE_FORMAT' => ['Invalid response received from server! Response format is not valid.',
52
+ 'The API response format is not in the expected structure'],
53
+ 'RESPONSE_COUNT' => ['Invalid parameters passed to function! Response data contains more than one result!',
54
+ 'The API response contains multiple results when only one was expected'],
55
+ 'RESPONSE_CODE' => ['Invalid parameters passed to function! Operation result or Error code not received from server',
56
+ 'The API response is missing required operation result or error code fields'],
57
+ 'RESPONSE_REST' => ['Invalid parameters passed to function! REST return is not a valid array!',
58
+ 'The REST response is not in a valid array format'],
59
+ 'CONTACT_INFO' => ['Invalid response received from server! Contact info is not a valid array or more than one contact info has returned!',
60
+ 'The contact information response is not in the expected format'],
61
+ 'CONTACT_SAVE' => ['Invalid response received from server! Contact info could not be saved!',
62
+ 'The contact information could not be saved on the server'],
63
+ 'DOMAIN_TRANSFER_REQUEST' => ['Invalid response received from server! Domain transfer request could not be completed!',
64
+ 'The domain transfer request failed to complete'],
65
+ 'DOMAIN_RENEW' => ['Invalid response received from server! Domain renew request could not be completed!',
66
+ 'The domain renewal request failed to complete'],
67
+ 'DOMAIN_REGISTER' => ['Invalid response received from server! Domain register request could not be completed!',
68
+ 'The domain registration request failed to complete'],
69
+ 'DOMAIN_SYNC' => ['Invalid response received from server! Domain sync request could not be completed!',
70
+ 'The domain synchronization request failed to complete']
71
+ }.freeze
72
+
73
+ # Gateway DomainStatus enum -> label.
74
+ DOMAIN_STATUS_MAP = {
75
+ 0 => 'Active', 1 => 'WaitingForRegistration', 2 => 'WaitingForDocument',
76
+ 3 => 'WaitingForIncomingTransfer', 4 => 'TransferredOut', 7 => 'PendingDelete',
77
+ 8 => 'Deleted', 9 => 'ConfirmationEmailSend', 11 => 'WaitingForOutgoingTransfer',
78
+ 12 => 'PendingHold', 15 => 'ModificationPending', 18 => 'InCase', 19 => 'PendingRestore'
79
+ }.freeze
80
+
81
+ # Zone record types the gateway accepts (probed against the live gateway).
82
+ ZONE_RECORD_TYPES = %w[A AAAA ALIAS CAA CNAME DS MX NAPTR PTR SRV TLSA TXT].freeze
83
+
84
+ # Redirect types accepted by /domains/forwards.
85
+ FORWARD_TYPES = %w[Standard Frame].freeze
86
+
87
+ HTTP_REASONS = {
88
+ 400 => 'Bad Request', 401 => 'Unauthorized', 402 => 'Payment Required', 403 => 'Forbidden',
89
+ 404 => 'Not Found', 405 => 'Method Not Allowed', 408 => 'Request Timeout', 409 => 'Conflict',
90
+ 422 => 'Unprocessable Entity', 429 => 'Too Many Requests', 500 => 'Internal Server Error',
91
+ 502 => 'Bad Gateway', 503 => 'Service Unavailable', 504 => 'Gateway Timeout'
92
+ }.freeze
93
+
94
+ BOILERPLATE_RE = /Exception of type '[^']*' was thrown/i.freeze
95
+
96
+ attr_accessor :zone_min_interval
97
+
98
+ # @param reseller_id [String] Reseller ID (UUID)
99
+ # @param api_key [String] API key
100
+ # @param test_mode [Boolean] use the OTE (test) gateway
101
+ # @param transport [#call, nil] HTTP transport (see NetHttpTransport)
102
+ # @param sentry_dsn [String, nil] Sentry DSN override ('' disables)
103
+ # @param zone_min_interval [Float, nil] pacing between /domains/zones calls
104
+ def initialize(reseller_id, api_key, test_mode = false, transport: nil, sentry_dsn: nil, zone_min_interval: nil)
105
+ @service_url = test_mode ? URL_OTE : URL_PROD
106
+ @reseller_id = reseller_id
107
+ @token = api_key
108
+ @transport = transport || NetHttpTransport.new
109
+ @ssl_verify = true
110
+ @error_reporting_enabled = true
111
+ @telemetry_minimal = false
112
+ @sentry_dsn = sentry_dsn.nil? ? DEFAULT_SENTRY_DSN : sentry_dsn.to_s
113
+ @telemetry_sender = nil
114
+ @zone_min_interval = zone_min_interval.nil? ? ZONE_MIN_INTERVAL : zone_min_interval.to_f
115
+ @last_zone_request_at = 0.0
116
+ @last_request = {}
117
+ @last_response = {}
118
+ @last_response_headers = {}
119
+ @last_parsed_response = {}
120
+ @last_function = ''
121
+ @start_at = monotonic
122
+ @start_wall = Time.now
123
+ end
124
+
125
+ # --- settings and diagnostics -----------------------------------------
126
+
127
+ def set_ssl_verification(enabled)
128
+ @ssl_verify = enabled ? true : false
129
+ nil
130
+ end
131
+
132
+ def set_transport(transport)
133
+ @transport = transport
134
+ nil
135
+ end
136
+
137
+ def get_request_data
138
+ @last_request
139
+ end
140
+
141
+ def set_request_data(request)
142
+ @last_request = request
143
+ self
144
+ end
145
+
146
+ def get_response_data
147
+ @last_response
148
+ end
149
+
150
+ def set_response_data(response)
151
+ @last_response = response
152
+ self
153
+ end
154
+
155
+ def get_response_headers
156
+ @last_response_headers
157
+ end
158
+
159
+ def set_response_headers(headers)
160
+ @last_response_headers = headers
161
+ self
162
+ end
163
+
164
+ def get_parsed_response_data
165
+ @last_parsed_response
166
+ end
167
+
168
+ def set_parsed_response_data(response)
169
+ @last_parsed_response = response
170
+ self
171
+ end
172
+
173
+ def get_last_function
174
+ @last_function
175
+ end
176
+
177
+ def set_last_function(function)
178
+ @last_function = function
179
+ self
180
+ end
181
+
182
+ def get_service_url
183
+ @service_url
184
+ end
185
+
186
+ def set_service_url(url)
187
+ @service_url = url
188
+ nil
189
+ end
190
+
191
+ # --- account -------------------------------------------------------------
192
+
193
+ def get_reseller_details
194
+ response = request('GET', 'deposit/accounts/me')
195
+ resp = {}
196
+ if Php.isset?(response, 'resellerId')
197
+ resp['result'] = RESULT_OK
198
+ resp['id'] = response['resellerId']
199
+ resp['active'] = true
200
+ resp['name'] = Php.get(response, 'resellerName') || ''
201
+ usd = Php.number_format(Php.get(response, 'usdBalance') || 0, 2)
202
+ resp['balance'] = usd
203
+ resp['currency'] = 'USD'
204
+ resp['symbol'] = '$'
205
+ resp['balances'] = [
206
+ { 'balance' => usd, 'currency' => 'USD', 'symbol' => '$' },
207
+ { 'balance' => Php.number_format(Php.get(response, 'tryBalance') || 0, 2), 'currency' => 'TL', 'symbol' => 'TL' }
208
+ ]
209
+ else
210
+ resp['result'] = RESULT_ERROR
211
+ resp['error'] = set_error('CREDENTIALS', 'Invalid response format', 'Response does not contain required fields')
212
+ end
213
+ resp
214
+ rescue ApiError => e
215
+ error_envelope(e, 'RESELLER_DETAILS')
216
+ end
217
+
218
+ CURRENCY_MAP = {
219
+ 'USD' => { 'id' => 2, 'name' => 'USD', 'symbol' => '$' },
220
+ 'TRY' => { 'id' => 1, 'name' => 'TL', 'symbol' => 'TL' },
221
+ 'EUR' => { 'id' => 3, 'name' => 'EUR', 'symbol' => '€' },
222
+ 'GBP' => { 'id' => 4, 'name' => 'GBP', 'symbol' => '£' }
223
+ }.freeze
224
+
225
+ def get_current_balance(currency_id = 'USD')
226
+ currency_name = Php.upper(currency_id)
227
+ mapped = CURRENCY_MAP[currency_name] || { 'id' => 0, 'name' => currency_name, 'symbol' => '' }
228
+ begin
229
+ response = request('GET', 'deposit/accounts/me', { 'currency' => currency_name })
230
+ balance_key = "#{Php.lower(currency_id)}Balance"
231
+ unless response.is_a?(Hash) && response.key?(balance_key)
232
+ # The account only reports USD and TRY balances.
233
+ return {
234
+ 'ErrorCode' => 'BALANCE',
235
+ 'OperationMessage' => "No #{currency_name} balance is reported for this account. Available: USD, TRY.",
236
+ 'OperationResult' => 'FAILED',
237
+ 'Balance' => '0.00',
238
+ 'CurrencyId' => mapped['id'],
239
+ 'CurrencyInfo' => nil,
240
+ 'CurrencyName' => mapped['name'],
241
+ 'CurrencySymbol' => mapped['symbol']
242
+ }
243
+ end
244
+ {
245
+ 'ErrorCode' => 0,
246
+ 'OperationMessage' => 'Command completed succesfully.',
247
+ 'OperationResult' => 'SUCCESS',
248
+ 'Balance' => Php.number_format(Php.get(response, balance_key) || 0, 2),
249
+ 'CurrencyId' => mapped['id'],
250
+ 'CurrencyInfo' => nil,
251
+ 'CurrencyName' => mapped['name'],
252
+ 'CurrencySymbol' => mapped['symbol']
253
+ }
254
+ rescue ApiError => e
255
+ {
256
+ 'ErrorCode' => format_error_code(e.code),
257
+ 'OperationMessage' => e.message,
258
+ 'OperationResult' => 'FAILED',
259
+ 'Balance' => '0.00',
260
+ 'CurrencyId' => mapped['id'],
261
+ 'CurrencyInfo' => nil,
262
+ 'CurrencyName' => mapped['name'],
263
+ 'CurrencySymbol' => mapped['symbol']
264
+ }
265
+ end
266
+ end
267
+
268
+ # --- search and lists ------------------------------------------------------
269
+
270
+ def check_availability(domains, extensions, period, command = 'create')
271
+ queries = []
272
+ Php.values(domains).each do |domain|
273
+ Php.values(extensions).each do |tld|
274
+ queries << { 'domainName' => "#{Php.to_s(domain)}.#{Php.ltrim(tld, '.')}" }
275
+ end
276
+ end
277
+
278
+ if queries.empty?
279
+ return {
280
+ 'result' => RESULT_ERROR,
281
+ 'error' => set_error('AVAILABILITY', 'Domain names not found.', 'No domain names provided for availability check')
282
+ }
283
+ end
284
+
285
+ response = request('POST', 'domains/bulk-search', queries)
286
+
287
+ data = []
288
+ items = Php.get(response, 'infos')
289
+ items = response if items.nil?
290
+ if Php.array?(items)
291
+ Php.values(items).each do |item|
292
+ next unless Php.array?(item)
293
+
294
+ raw_tld = Php.get(item, 'tld')
295
+ if raw_tld.nil?
296
+ name = Php.to_s(Php.get(item, 'domainName') || '')
297
+ dot = name.rindex('.')
298
+ raw_tld = dot ? name[(dot + 1)..] : ''
299
+ end
300
+ tld = Php.lower(raw_tld)
301
+ domain_raw = Php.get(item, 'domainName') || ''
302
+ lower = Php.lower(domain_raw)
303
+ suffix = ".#{tld}"
304
+ domain_name = tld != '' && lower.end_with?(suffix) ? lower[0, lower.length - suffix.length] : lower
305
+ status = Php.lower(Php.get(item, 'status') || '')
306
+ available = %w[available 1 true].include?(status)
307
+ price = Php.get(item, 'price')
308
+ period_out = Php.get(item, 'period')
309
+ is_fee = Php.get(item, 'isPremium')
310
+
311
+ data << {
312
+ 'TLD' => tld,
313
+ 'DomainName' => domain_name,
314
+ 'Status' => available ? 'available' : 'notavailable',
315
+ 'Command' => command,
316
+ 'Period' => period_out.nil? ? period : period_out,
317
+ 'IsFee' => is_fee.nil? ? false : is_fee,
318
+ 'Price' => price.nil? ? nil : Php.number_format(price, 4),
319
+ 'Currency' => Php.get(item, 'currency'),
320
+ 'Reason' => Php.get(item, 'reason')
321
+ }
322
+ end
323
+ end
324
+ data
325
+ rescue ApiError => e
326
+ error_envelope(e, 'AVAILABILITY')
327
+ end
328
+
329
+ def get_list(extra_parameters = {})
330
+ params = Php.to_hash(extra_parameters)
331
+ params['MaxResultCount'] = Php.to_i(params['PageSize']) unless params['PageSize'].nil?
332
+ unless params['PageNumber'].nil?
333
+ page_size = Php.to_i(params['PageSize'].nil? ? 200 : params['PageSize'])
334
+ params['SkipCount'] = Php.to_i(params['PageNumber']) * page_size
335
+ end
336
+ unless params['OrderColumn'].nil?
337
+ direction = Php.upper(params['OrderDirection'].nil? ? 'ASC' : params['OrderDirection']) == 'DESC' ? 'DESC' : 'ASC'
338
+ params['Sorting'] = "#{Php.to_s(params['OrderColumn'])} #{direction}"
339
+ end
340
+ %w[PageNumber PageSize OrderColumn OrderDirection].each { |k| params.delete(k) }
341
+
342
+ merged = { 'MaxResultCount' => 200, 'SkipCount' => 0 }.merge(params)
343
+ response = request('GET', 'domains', merged)
344
+
345
+ items = Php.get(response, 'items')
346
+ domains = if items.nil?
347
+ []
348
+ else
349
+ php_map(items) { |item| list_item(item) }
350
+ end
351
+
352
+ {
353
+ 'data' => { 'Domains' => domains },
354
+ 'result' => RESULT_OK,
355
+ 'TotalCount' => Php.to_i(Php.get(response, 'totalCount') || 0)
356
+ }
357
+ rescue ApiError => e
358
+ error_envelope(e, 'DOMAIN_LIST')
359
+ end
360
+
361
+ PRICE_TYPES = {
362
+ 'register' => 'registration', 'renew' => 'renew', 'transfer' => 'transfer',
363
+ 'restore' => 'restore', 'refund' => 'refund', 'backorder' => 'backorder'
364
+ }.freeze
365
+
366
+ def get_tld_list(count = 20)
367
+ response = request('GET', 'products/tlds', { 'MaxResultCount' => count, 'SkipCount' => 0 })
368
+
369
+ tld_data = []
370
+ id_counter = 1
371
+ items = Php.get(response, 'items')
372
+ if Php.array?(items)
373
+ Php.values(items).each do |tld|
374
+ pricing = {}
375
+ currencies = {}
376
+ first_price = Php.get(tld, 'prices', 0)
377
+ if Php.array?(first_price)
378
+ PRICE_TYPES.each do |api_type, out_type|
379
+ api_value = Php.get(first_price, api_type)
380
+ next if api_value.nil?
381
+
382
+ if Php.array?(api_value) && Php.isset?(api_value, 0)
383
+ Php.values(api_value).each do |info|
384
+ next unless Php.array?(info)
385
+
386
+ add_price(pricing, currencies, out_type, info)
387
+ end
388
+ elsif Php.array?(api_value)
389
+ add_price(pricing, currencies, out_type, api_value)
390
+ end
391
+ end
392
+ end
393
+
394
+ pricing.each_key do |type|
395
+ pricing[type] = pricing[type].sort_by { |k, _| k }.to_h { |k, v| [k.to_s, v] }
396
+ end
397
+
398
+ max_char = Php.get(tld, 'constraints', 'maxLenght')
399
+ max_period = Php.get(tld, 'maxRegistrationPeriod')
400
+ min_char = Php.get(tld, 'constraints', 'minLength')
401
+ min_period = Php.get(tld, 'minRegistrationPeriod')
402
+ status = Php.get(tld, 'status')
403
+ tld_data << {
404
+ 'id' => id_counter,
405
+ 'status' => status.nil? ? 'Active' : status,
406
+ 'maxchar' => max_char.nil? ? 63 : max_char,
407
+ 'maxperiod' => max_period.nil? ? 10 : max_period,
408
+ 'minchar' => min_char.nil? ? 1 : min_char,
409
+ 'minperiod' => min_period.nil? ? 1 : min_period,
410
+ 'tld' => Php.get(tld, 'name'),
411
+ 'pricing' => pricing,
412
+ 'currencies' => currencies
413
+ }
414
+ id_counter += 1
415
+ end
416
+ end
417
+
418
+ { 'data' => tld_data, 'result' => RESULT_OK }
419
+ rescue ApiError => e
420
+ error_envelope(e, 'TLD_LIST')
421
+ end
422
+
423
+ # --- domain ----------------------------------------------------------------
424
+
425
+ def get_details(domain_name)
426
+ response = request('GET', 'domains/info', { 'DomainName' => domain_name })
427
+ parse_domain_info(response)
428
+ rescue ApiError => e
429
+ error_envelope(e, 'DOMAIN_DETAILS')
430
+ end
431
+
432
+ def sync_from_registry(domain_name)
433
+ get_details(domain_name)
434
+ end
435
+
436
+ def modify_name_server(domain_name, name_servers)
437
+ payload = { 'domainName' => domain_name, 'nameServers' => normalize_host_name_list(name_servers) }
438
+ response = request('PUT', 'domains/dns/name-server', payload)
439
+ ns = Php.get(response, 'nameServers')
440
+ { 'result' => RESULT_OK, 'data' => { 'NameServers' => ns.nil? ? payload['nameServers'] : ns } }
441
+ rescue ApiError => e
442
+ error_envelope(e, 'MODIFY_NS')
443
+ end
444
+
445
+ def enable_theft_protection_lock(domain_name)
446
+ request('POST', 'domains/lock', { 'domainName' => domain_name })
447
+ { 'result' => RESULT_OK, 'data' => { 'LockStatus' => true } }
448
+ rescue ApiError => e
449
+ error_envelope(e, 'ENABLE_LOCK')
450
+ end
451
+
452
+ def disable_theft_protection_lock(domain_name)
453
+ request('POST', 'domains/unlock', { 'domainName' => domain_name })
454
+ { 'result' => RESULT_OK, 'data' => { 'LockStatus' => false } }
455
+ rescue ApiError => e
456
+ error_envelope(e, 'DISABLE_LOCK')
457
+ end
458
+
459
+ # `reason` is kept for signature compatibility only: the gateway's
460
+ # DomainPrivacyInput has no reason field, so it is not sent.
461
+ def modify_privacy_protection_status(domain_name, status, _reason = DEFAULT_REASON)
462
+ enabled = if status == true || status == false
463
+ status
464
+ elsif Php.array?(status)
465
+ false
466
+ else
467
+ Php.filter_bool(status)
468
+ end
469
+ request('POST', 'domains/privacy', { 'domainName' => domain_name, 'privacyStatus' => enabled })
470
+ { 'data' => { 'PrivacyProtectionStatus' => enabled }, 'result' => RESULT_OK }
471
+ rescue ApiError => e
472
+ error_envelope(e, 'MODIFY_PRIVACY')
473
+ end
474
+
475
+ def renew(domain_name, period)
476
+ response = request('POST', 'domains/renew', { 'domainName' => domain_name, 'period' => period })
477
+ failed = operation_failure(response, 'RENEW_DOMAIN')
478
+ return failed if failed
479
+
480
+ expiration = Php.get(response, 'expirationDate')
481
+ if Php.truthy?(expiration)
482
+ { 'result' => RESULT_OK, 'data' => { 'ExpirationDate' => format_api_date(expiration) } }
483
+ else
484
+ send_error_to_sentry_async(ApiError.new("[DOMAIN_RENEW] #{DEFAULT_ERRORS['DOMAIN_RENEW'][1]}", 0))
485
+ { 'result' => RESULT_ERROR, 'error' => set_error('DOMAIN_RENEW') }
486
+ end
487
+ rescue ApiError => e
488
+ error_envelope(e, 'RENEW_DOMAIN')
489
+ end
490
+
491
+ def register_with_contact_info(domain_name, period, contacts, name_servers = [], epp_lock = true,
492
+ privacy_lock = false, additional_attributes = {})
493
+ payload_contacts = Php.pairs(Php.stringify_keys(contacts)).map do |type, details|
494
+ parse_contact(details, Php.ucfirst(Php.lower(type)))
495
+ end
496
+
497
+ ns = normalize_host_name_list(name_servers)
498
+ ns = DEFAULT_NAMESERVERS.dup if ns.empty?
499
+
500
+ attrs = Php.to_hash(Php.to_array(Php.stringify_keys(additional_attributes)))
501
+ attrs = normalize_tr_attributes(attrs) if Php.lower(domain_name).end_with?('.tr')
502
+ attrs = stringify_attributes(attrs)
503
+
504
+ payload = {
505
+ 'domainName' => domain_name,
506
+ 'period' => period,
507
+ 'nameServers' => ns,
508
+ 'isLocked' => epp_lock,
509
+ 'privacyEnabled' => privacy_lock,
510
+ 'contacts' => payload_contacts,
511
+ 'tldAttributes' => attrs
512
+ }
513
+ response = request('POST', 'domains/register-with-contacts', payload)
514
+ failed = operation_failure(response, 'REGISTER_DOMAIN')
515
+ return failed if failed
516
+
517
+ parse_domain_info(response)
518
+ rescue ApiError => e
519
+ error_envelope(e, 'REGISTER_DOMAIN')
520
+ end
521
+
522
+ # --- contacts --------------------------------------------------------------
523
+
524
+ CONTACT_TYPE_IN = { 'Admin' => 'Administrative', 'Billing' => 'Billing', 'Tech' => 'Technical',
525
+ 'Registrant' => 'Registrant' }.freeze
526
+ # get_contacts: matched case-insensitively, long and short spellings.
527
+ CONTACT_TYPE_LOOKUP = {
528
+ 'admin' => 'Administrative', 'administrative' => 'Administrative', 'billing' => 'Billing',
529
+ 'tech' => 'Technical', 'technical' => 'Technical', 'registrant' => 'Registrant', 'owner' => 'Registrant'
530
+ }.freeze
531
+ CONTACT_TYPE_OUT = { 'Administrative' => 'Admin', 'Technical' => 'Tech', 'Billing' => 'Billing',
532
+ 'Registrant' => 'Registrant' }.freeze
533
+
534
+ def get_contacts(domain_name)
535
+ info = request('GET', 'domains/info', { 'DomainName' => domain_name })
536
+ contacts = {}
537
+ list = Php.get(info, 'contacts')
538
+ if Php.array?(list)
539
+ Php.values(list).each do |item|
540
+ next unless Php.array?(item)
541
+
542
+ api_type = Php.trim(Php.to_s(first_set(Php.get(item, 'contactType'), '')))
543
+ key = CONTACT_TYPE_LOOKUP[Php.lower(api_type)] || Php.ucfirst(Php.lower(api_type))
544
+ next if key == ''
545
+
546
+ merged = Php.to_hash(item).merge('type' => key)
547
+ contacts[key] = parse_contact_info(merged)
548
+ end
549
+ end
550
+
551
+ ordered = {}
552
+ %w[Administrative Billing Registrant Technical].each do |key|
553
+ ordered[key] = contacts.delete(key) unless contacts[key].nil?
554
+ end
555
+ contacts.each { |key, contact| ordered[key] = contact }
556
+ { 'data' => { 'contacts' => ordered }, 'result' => RESULT_OK }
557
+ rescue ApiError => e
558
+ error_envelope(e, 'GET_CONTACTS')
559
+ end
560
+
561
+ def save_contacts(domain_name, contacts)
562
+ payload_contacts = Php.pairs(Php.stringify_keys(contacts)).map do |type, details|
563
+ parse_contact(details, Php.ucfirst(Php.lower(type)))
564
+ end
565
+ response = request('PUT', 'domains/contacts/update', { 'domainName' => domain_name, 'contacts' => payload_contacts })
566
+
567
+ parsed = {}
568
+ list = Php.get(response, 'contacts')
569
+ if Php.array?(list)
570
+ Php.values(list).each do |contact|
571
+ parsed[Php.ucfirst(Php.lower(Php.get(contact, 'type')))] = parse_contact_info(contact)
572
+ end
573
+ end
574
+ { 'result' => RESULT_OK, 'data' => { 'contacts' => parsed } }
575
+ rescue ApiError => e
576
+ error_envelope(e, 'SAVE_CONTACTS')
577
+ end
578
+
579
+ # --- child nameservers -----------------------------------------------------
580
+
581
+ def add_child_name_server(domain_name, name_server, ip_address)
582
+ payload = {
583
+ 'domainName' => domain_name,
584
+ 'hostName' => normalize_host_name(name_server),
585
+ 'ipAddresses' => [{ 'ipAddress' => ip_address, 'ipVersion' => Php.ipv4?(ip_address) ? 'v4' : 'v6' }]
586
+ }
587
+ request('POST', 'domains/dns/host', payload)
588
+ { 'result' => RESULT_OK, 'data' => { 'NameServer' => name_server, 'IPAdresses' => [ip_address] } }
589
+ rescue ApiError => e
590
+ error_envelope(e, 'ADD_CHILD_NS')
591
+ end
592
+
593
+ def delete_child_name_server(domain_name, name_server)
594
+ request('DELETE', 'domains/dns/host', { 'domainName' => domain_name, 'hostName' => normalize_host_name(name_server) })
595
+ { 'result' => RESULT_OK, 'data' => { 'NameServer' => name_server } }
596
+ rescue ApiError => e
597
+ error_envelope(e, 'DELETE_CHILD_NS')
598
+ end
599
+
600
+ def modify_child_name_server(domain_name, name_server, ip_address)
601
+ host = normalize_host_name(name_server)
602
+ payload = {
603
+ 'domainName' => domain_name,
604
+ 'hostName' => host,
605
+ 'newHostName' => host,
606
+ 'ipAddresses' => [{ 'ipAddress' => ip_address, 'ipVersion' => Php.ipv4?(ip_address) ? 'v4' : 'v6' }]
607
+ }
608
+ response = request('PUT', 'domains/dns/host', payload)
609
+ host_out = Php.get(response, 'hostName')
610
+ ips_out = Php.get(response, 'ipAddresses')
611
+ {
612
+ 'result' => RESULT_OK,
613
+ 'data' => { 'NameServer' => host_out.nil? ? host : host_out, 'IPAdresses' => ips_out.nil? ? [ip_address] : ips_out }
614
+ }
615
+ rescue ApiError => e
616
+ error_envelope(e, 'MODIFY_CHILD_NS')
617
+ end
618
+
619
+ # --- transfers -------------------------------------------------------------
620
+
621
+ # Contacts are never sent with a transfer: the registrar handles them, so
622
+ # the request always carries an empty contact list.
623
+ def transfer(domain_name, epp_code, period)
624
+ payload = { 'domainName' => domain_name, 'authCode' => epp_code, 'period' => period, 'contacts' => [] }
625
+ response = request('POST', 'domains/transfer', payload)
626
+ failed = operation_failure(response, 'TRANSFER_DOMAIN')
627
+ return failed if failed
628
+
629
+ parse_domain_info(response)
630
+ rescue ApiError => e
631
+ error_envelope(e, 'TRANSFER_DOMAIN')
632
+ end
633
+
634
+ def cancel_transfer(domain_name)
635
+ transfer_action(domain_name, 'cancel', 'Cancelled', 'CANCEL_TRANSFER')
636
+ end
637
+
638
+ def approve_transfer(domain_name)
639
+ transfer_action(domain_name, 'approve', 'Approved', 'APPROVE_TRANSFER')
640
+ end
641
+
642
+ def reject_transfer(domain_name)
643
+ transfer_action(domain_name, 'reject', 'Rejected', 'REJECT_TRANSFER')
644
+ end
645
+
646
+ def check_transfer(domain_name, auth_code)
647
+ response = request('POST', 'domains/transfers/check', { 'domainName' => domain_name, 'authCode' => auth_code })
648
+
649
+ available = Php.truthy?(Php.get(response, 'transferAvailabilityStatus'))
650
+ additional = Php.get(response, 'additionalAttributes')
651
+ message = Php.get(response, 'message')
652
+ message_key = Php.get(response, 'messageKey')
653
+ envelope = {
654
+ 'result' => available ? RESULT_OK : RESULT_ERROR,
655
+ 'data' => {
656
+ 'TransferAvailabilityStatus' => available,
657
+ 'AuthCodeIsRequired' => Php.truthy?(Php.get(response, 'authCodeIsRequired')),
658
+ 'AuthCodeIsValid' => Php.truthy?(Php.get(response, 'authCodeIsValid')),
659
+ 'UserTransferRequired' => Php.truthy?(Php.get(response, 'userTransferRequired')),
660
+ 'TransferLock' => Php.truthy?(Php.get(response, 'transferLock')),
661
+ 'Message' => Php.to_s(message.nil? ? '' : message),
662
+ 'MessageKey' => Php.to_s(message_key.nil? ? '' : message_key),
663
+ 'Additional' => additional.nil? ? {} : Php.to_array(additional)
664
+ }
665
+ }
666
+ unless available
667
+ envelope['error'] = set_error(
668
+ 'TRANSFER_NOT_AVAILABLE',
669
+ message.nil? ? 'Domain is not transferable' : message,
670
+ message_key.nil? ? (message.nil? ? '' : message) : message_key
671
+ )
672
+ end
673
+ envelope
674
+ rescue ApiError => e
675
+ error_envelope(e, 'CHECK_TRANSFER')
676
+ end
677
+
678
+ # --- DNS zone --------------------------------------------------------------
679
+
680
+ def get_resource_records(domain_name)
681
+ response = request('GET', 'domains/zones', { 'domainName' => domain_name })
682
+
683
+ records = []
684
+ Php.values(Php.to_array(response)).each do |set|
685
+ next unless Php.array?(set)
686
+
687
+ name = Php.get(set, 'name') || ''
688
+ type = Php.get(set, 'type') || ''
689
+ ttl = Php.to_i(Php.get(set, 'ttl') || 0)
690
+ rows = Php.get(set, 'records') || []
691
+ if !Php.array?(rows) || rows.empty?
692
+ records << { 'Name' => name, 'Type' => type, 'TTL' => ttl, 'Content' => '', 'Disabled' => false }
693
+ next
694
+ end
695
+ Php.values(rows).each do |rec|
696
+ content = Php.get(rec, 'content')
697
+ records << {
698
+ 'Name' => name, 'Type' => type, 'TTL' => ttl,
699
+ 'Content' => content.nil? ? '' : content,
700
+ 'Disabled' => Php.truthy?(Php.get(rec, 'disabled'))
701
+ }
702
+ end
703
+ end
704
+
705
+ { 'result' => RESULT_OK, 'data' => { 'records' => records } }
706
+ rescue ApiError => e
707
+ error_envelope(e, 'GET_ZONE')
708
+ end
709
+
710
+ def add_resource_record(domain_name, name, type, content, ttl = 3600, apply = true)
711
+ if (why = unsupported_zone_type(type))
712
+ return { 'result' => RESULT_ERROR, 'error' => set_error('ADD_ZONE', why, why) }
713
+ end
714
+
715
+ name = relativize_record_name(name, domain_name)
716
+ body = { 'zoneStruct' => build_zone_struct(name, type, content, ttl) }
717
+ request('POST', "domains/zones?#{Php.http_build_query({ 'domainName' => domain_name })}", body)
718
+ if Php.truthy?(apply)
719
+ applied = apply_resource_records(domain_name)
720
+ return applied if applied['result'] != RESULT_OK
721
+ end
722
+
723
+ { 'result' => RESULT_OK,
724
+ 'data' => { 'Name' => name, 'Type' => body['zoneStruct']['type'], 'TTL' => body['zoneStruct']['ttl'], 'Content' => content } }
725
+ rescue ApiError => e
726
+ error_envelope(e, 'ADD_ZONE')
727
+ end
728
+
729
+ def edit_resource_record(domain_name, record_name, name, type, content, ttl = 3600, apply = true)
730
+ if (why = unsupported_zone_type(type))
731
+ return { 'result' => RESULT_ERROR, 'error' => set_error('EDIT_ZONE', why, why) }
732
+ end
733
+
734
+ name = relativize_record_name(name, domain_name)
735
+ body = { 'zoneStruct' => build_zone_struct(name, type, content, ttl) }
736
+ query = Php.http_build_query({ 'domainName' => domain_name, 'recordName' => record_name })
737
+ request('PUT', "domains/zones?#{query}", body)
738
+ if Php.truthy?(apply)
739
+ applied = apply_resource_records(domain_name)
740
+ return applied if applied['result'] != RESULT_OK
741
+ end
742
+
743
+ { 'result' => RESULT_OK,
744
+ 'data' => { 'Name' => name, 'Type' => body['zoneStruct']['type'], 'TTL' => body['zoneStruct']['ttl'], 'Content' => content } }
745
+ rescue ApiError => e
746
+ error_envelope(e, 'EDIT_ZONE')
747
+ end
748
+
749
+ def delete_resource_record(domain_name, name, record, record_type, apply = true)
750
+ request('DELETE', 'domains/zones', {
751
+ 'domainName' => domain_name, 'Name' => name, 'Record' => record, 'RecordType' => record_type
752
+ })
753
+ if Php.truthy?(apply)
754
+ applied = apply_resource_records(domain_name)
755
+ return applied if applied['result'] != RESULT_OK
756
+ end
757
+
758
+ { 'result' => RESULT_OK, 'data' => { 'Name' => name, 'Type' => record_type, 'Content' => record } }
759
+ rescue ApiError => e
760
+ error_envelope(e, 'DELETE_ZONE')
761
+ end
762
+
763
+ def apply_resource_records(domain_name)
764
+ request('POST', "domains/zones/apply?#{Php.http_build_query({ 'domainName' => domain_name })}", [])
765
+ { 'result' => RESULT_OK, 'data' => { 'DomainName' => domain_name } }
766
+ rescue ApiError => e
767
+ error_envelope(e, 'APPLY_ZONE')
768
+ end
769
+
770
+ def set_resource_record_set(domain_name, name, type, contents, ttl = 3600, apply = true)
771
+ raise TypeError, 'contents must be an Array' unless Php.array?(contents)
772
+
773
+ if (why = unsupported_zone_type(type))
774
+ return { 'result' => RESULT_ERROR, 'error' => set_error('SET_ZONE', why, why) }
775
+ end
776
+
777
+ name = relativize_record_name(name, domain_name)
778
+ contents = Php.values(contents).map { |v| Php.to_s(v) }.reject { |v| Php.trim(v) == '' }
779
+
780
+ return delete_resource_record_set(domain_name, name, type, apply) if contents.empty?
781
+
782
+ body = { 'zoneStruct' => build_zone_struct(name, type, contents, ttl) }
783
+ request('POST', "domains/zones?#{Php.http_build_query({ 'domainName' => domain_name })}", body)
784
+ if Php.truthy?(apply)
785
+ applied = apply_resource_records(domain_name)
786
+ return applied if applied['result'] != RESULT_OK
787
+ end
788
+
789
+ { 'result' => RESULT_OK,
790
+ 'data' => { 'Name' => name, 'Type' => body['zoneStruct']['type'], 'TTL' => body['zoneStruct']['ttl'], 'Contents' => contents } }
791
+ rescue ApiError => e
792
+ error_envelope(e, 'SET_ZONE')
793
+ end
794
+
795
+ def delete_resource_record_set(domain_name, name, type, apply = true, known_value = '')
796
+ type = Php.upper(Php.trim(type))
797
+ value = Php.to_s(known_value)
798
+
799
+ if value == ''
800
+ current = get_resource_records(domain_name)
801
+ return current if current['result'] != RESULT_OK
802
+
803
+ fqdn = qualify_record_name(name, domain_name)
804
+ current['data']['records'].each do |rec|
805
+ next unless Php.casecmp_eq?(rec['Name'], fqdn) && Php.casecmp_eq?(rec['Type'], type)
806
+
807
+ value = Php.to_s(rec['Content'])
808
+ break
809
+ end
810
+ return { 'result' => RESULT_OK, 'data' => { 'Name' => name, 'Type' => type, 'Removed' => false } } if value == ''
811
+ end
812
+
813
+ delete_resource_record(domain_name, qualify_record_name(name, domain_name), value, type, apply)
814
+ rescue ApiError => e
815
+ error_envelope(e, 'DELETE_ZONE')
816
+ end
817
+
818
+ def add_resource_record_value(domain_name, name, type, value, ttl = nil, apply = true)
819
+ if (why = unsupported_zone_type(type))
820
+ return { 'result' => RESULT_ERROR, 'error' => set_error('ADD_ZONE', why, why) }
821
+ end
822
+
823
+ name = relativize_record_name(name, domain_name)
824
+ set = read_resource_record_set(domain_name, name, type)
825
+ return set if set.key?('result')
826
+
827
+ if set['contents'].any? { |existing| same_record_value?(type, existing, value) }
828
+ return { 'result' => RESULT_OK,
829
+ 'data' => { 'Name' => name, 'Type' => Php.upper(type), 'Contents' => set['contents'], 'Changed' => false } }
830
+ end
831
+
832
+ contents = set['contents'] + [Php.to_s(value)]
833
+ set_resource_record_set(domain_name, name, type, contents, ttl.nil? ? fallback_ttl(set) : ttl, apply)
834
+ rescue ApiError => e
835
+ error_envelope(e, 'ADD_ZONE')
836
+ end
837
+
838
+ def remove_resource_record_value(domain_name, name, type, value, apply = true)
839
+ if (why = unsupported_zone_type(type))
840
+ return { 'result' => RESULT_ERROR, 'error' => set_error('DELETE_ZONE', why, why) }
841
+ end
842
+
843
+ name = relativize_record_name(name, domain_name)
844
+ set = read_resource_record_set(domain_name, name, type)
845
+ return set if set.key?('result')
846
+
847
+ remaining = []
848
+ found = false
849
+ set['contents'].each do |existing|
850
+ if !found && same_record_value?(type, existing, value)
851
+ found = true
852
+ next
853
+ end
854
+ remaining << existing
855
+ end
856
+
857
+ unless found
858
+ return { 'result' => RESULT_OK,
859
+ 'data' => { 'Name' => name, 'Type' => Php.upper(type), 'Contents' => set['contents'], 'Changed' => false } }
860
+ end
861
+
862
+ return delete_resource_record_set(domain_name, name, type, apply, Php.to_s(value)) if remaining.empty?
863
+
864
+ set_resource_record_set(domain_name, name, type, remaining, fallback_ttl(set), apply)
865
+ rescue ApiError => e
866
+ error_envelope(e, 'DELETE_ZONE')
867
+ end
868
+
869
+ def replace_resource_record_value(domain_name, name, type, old_value, new_value, ttl = nil, apply = true)
870
+ if (why = unsupported_zone_type(type))
871
+ return { 'result' => RESULT_ERROR, 'error' => set_error('EDIT_ZONE', why, why) }
872
+ end
873
+
874
+ name = relativize_record_name(name, domain_name)
875
+ set = read_resource_record_set(domain_name, name, type)
876
+ return set if set.key?('result')
877
+
878
+ contents = []
879
+ found = false
880
+ set['contents'].each do |existing|
881
+ if !found && same_record_value?(type, existing, old_value)
882
+ found = true
883
+ contents << Php.to_s(new_value)
884
+ next
885
+ end
886
+ contents << existing
887
+ end
888
+
889
+ unless found
890
+ why = format('No %<type>s record "%<name>s" with value "%<value>s" to edit.',
891
+ type: Php.upper(type), name: name, value: Php.to_s(old_value))
892
+ return { 'result' => RESULT_ERROR, 'error' => set_error('EDIT_ZONE', why, why) }
893
+ end
894
+
895
+ set_resource_record_set(domain_name, name, type, contents, ttl.nil? ? fallback_ttl(set) : ttl, apply)
896
+ rescue ApiError => e
897
+ error_envelope(e, 'EDIT_ZONE')
898
+ end
899
+
900
+ # --- forwarding ------------------------------------------------------------
901
+
902
+ def get_forwarding(domain_name)
903
+ response = request('GET', 'domains/forwards', { 'domainName' => domain_name })
904
+ alias_ = Php.get(response, 'redirectAlias')
905
+ type = Php.get(response, 'redirectType')
906
+ {
907
+ 'result' => RESULT_OK,
908
+ 'data' => {
909
+ 'Enabled' => Php.truthy?(alias_),
910
+ 'RedirectAddress' => Php.to_s(alias_.nil? ? '' : alias_),
911
+ 'ForwardType' => Php.to_s(type.nil? ? '' : type)
912
+ }
913
+ }
914
+ rescue ApiError => e
915
+ raw = Php.get(@last_response, 'raw_response')
916
+ raw = e.message if raw.nil?
917
+ if forward_not_set?(e)
918
+ return { 'result' => RESULT_OK, 'data' => { 'Enabled' => false, 'RedirectAddress' => '', 'ForwardType' => '' } }
919
+ end
920
+
921
+ details = Php.get(@last_parsed_response, 'Details')
922
+ { 'result' => RESULT_ERROR, 'error' => set_error(format_error_code(e.code), e.message, details.nil? ? raw : details) }
923
+ end
924
+
925
+ def set_forwarding(domain_name, redirect_address, forward_type = 'Standard')
926
+ type = Php.ucfirst(Php.lower(Php.trim(forward_type)))
927
+ unless FORWARD_TYPES.include?(type)
928
+ why = format('Forward type "%<type>s" is not supported. Supported types: %<list>s.',
929
+ type: Php.to_s(forward_type), list: FORWARD_TYPES.join(', '))
930
+ return { 'result' => RESULT_ERROR, 'error' => set_error('SET_FORWARD', why, why) }
931
+ end
932
+ if Php.trim(redirect_address) == ''
933
+ why = 'Redirect address is required.'
934
+ return { 'result' => RESULT_ERROR, 'error' => set_error('SET_FORWARD', why, why) }
935
+ end
936
+
937
+ request('POST', 'domains/forwards', {
938
+ 'domainName' => domain_name, 'redirectAddress' => redirect_address, 'forwardType' => type
939
+ })
940
+ { 'result' => RESULT_OK, 'data' => { 'RedirectAddress' => redirect_address, 'ForwardType' => type } }
941
+ rescue ApiError => e
942
+ error_envelope(e, 'SET_FORWARD')
943
+ end
944
+
945
+ def delete_forwarding(domain_name)
946
+ request('DELETE', 'domains/forwards', { 'DomainName' => domain_name })
947
+ { 'result' => RESULT_OK, 'data' => { 'DomainName' => domain_name, 'Removed' => true } }
948
+ rescue ApiError => e
949
+ # Nothing configured: deleting is a no-op.
950
+ return { 'result' => RESULT_OK, 'data' => { 'DomainName' => domain_name, 'Removed' => false } } if forward_not_set?(e)
951
+
952
+ error_envelope(e, 'DELETE_FORWARD')
953
+ end
954
+
955
+ # --- utilities (no network) --------------------------------------------------
956
+
957
+ def is_tr_tld(domain)
958
+ str = Php.to_s(domain).b
959
+ tail = str.bytesize >= 3 ? str.byteslice(-3, 3) : str
960
+ Php.lower(tail) == '.tr'
961
+ end
962
+
963
+ VALIDATE_DEFAULTS = {
964
+ 'FirstName' => 'Isimyok',
965
+ 'LastName' => 'Isimyok',
966
+ 'AddressLine1' => 'Addres yok',
967
+ 'City' => 'ISTANBUL',
968
+ 'Country' => 'TR',
969
+ 'ZipCode' => '34000',
970
+ 'Phone' => '5555555555',
971
+ 'PhoneCountryCode' => '90'
972
+ }.freeze
973
+
974
+ def validate_contact(contact)
975
+ contact = Php.to_hash(contact)
976
+ VALIDATE_DEFAULTS.each { |key, value| contact[key] = value if contact[key].nil? }
977
+ # No State default: a missing state falls back to the city below.
978
+ contact['State'] = '' if contact['State'].nil?
979
+
980
+ contact['FirstName'] = sanitize_person_name(contact['FirstName'])
981
+ contact['LastName'] = sanitize_person_name(contact['LastName'])
982
+
983
+ contact['FirstName'] = VALIDATE_DEFAULTS['FirstName'] if Php.trim(contact['FirstName']).empty?
984
+ contact['LastName'] = contact['FirstName'] if Php.trim(contact['LastName']).empty?
985
+ contact['AddressLine1'] = VALIDATE_DEFAULTS['AddressLine1'] if Php.trim(contact['AddressLine1']).empty?
986
+ contact['City'] = VALIDATE_DEFAULTS['City'] if Php.trim(contact['City']).empty?
987
+ contact['State'] = contact['City'] if Php.trim(contact['State']).empty?
988
+ contact['Country'] = normalize_country_code(contact['Country'])
989
+ contact['Country'] = VALIDATE_DEFAULTS['Country'] if Php.trim(contact['Country']).empty?
990
+ contact['ZipCode'] = VALIDATE_DEFAULTS['ZipCode'] if Php.trim(contact['ZipCode']).empty?
991
+
992
+ phone, phone_cc = normalize_phone(contact['Phone'], contact['PhoneCountryCode'])
993
+ contact['Phone'] = phone
994
+ contact['PhoneCountryCode'] = phone_cc
995
+ contact['PhoneCountryCode'] = VALIDATE_DEFAULTS['PhoneCountryCode'] if Php.trim(contact['PhoneCountryCode']).empty?
996
+ contact['Phone'] = VALIDATE_DEFAULTS['Phone'] if Php.trim(contact['Phone']).empty?
997
+
998
+ fax, fax_cc = normalize_phone(contact['Fax'].nil? ? '' : contact['Fax'],
999
+ contact['FaxCountryCode'].nil? ? '' : contact['FaxCountryCode'])
1000
+ contact['Fax'] = fax
1001
+ contact['FaxCountryCode'] = fax == '' ? '' : fax_cc
1002
+
1003
+ contact
1004
+ end
1005
+
1006
+ private
1007
+
1008
+ # --- HTTP --------------------------------------------------------------------
1009
+
1010
+ def request(method, endpoint, data = {})
1011
+ pace_zone_request(endpoint)
1012
+
1013
+ @transport_failed = false
1014
+ @last_gateway_code = ''
1015
+
1016
+ @start_at = monotonic
1017
+ @start_wall = Time.now
1018
+ @last_function = "request:#{method} #{endpoint.to_s.split('?', 2).first}"
1019
+
1020
+ url = "#{@service_url}/#{endpoint.to_s.sub(%r{\A/+}, '')}"
1021
+ @last_request = { 'url' => url, 'method' => method, 'payload' => data }
1022
+
1023
+ headers = {
1024
+ 'Content-Type' => 'application/json',
1025
+ 'Accept' => 'application/json',
1026
+ 'X-API-KEY' => @token.to_s,
1027
+ '__reseller' => @reseller_id.to_s,
1028
+ 'User-Agent' => USER_AGENT
1029
+ }
1030
+
1031
+ body = nil
1032
+ if %w[GET DELETE].include?(method)
1033
+ url += "?#{Php.http_build_query(data)}" unless Php.empty?(data)
1034
+ else
1035
+ body = Php.json_encode(data)
1036
+ end
1037
+
1038
+ begin
1039
+ raw = @transport.call(
1040
+ method: method, url: url, headers: headers, body: body,
1041
+ timeout: DEFAULT_TIMEOUT, connect_timeout: DEFAULT_CONNECT_TIMEOUT, verify_ssl: @ssl_verify
1042
+ )
1043
+ rescue TransportError => e
1044
+ transport_failure(url, e.code, e.message)
1045
+ rescue StandardError => e
1046
+ transport_failure(url, 0, e.message)
1047
+ end
1048
+
1049
+ status, response_body, response_headers = unpack_response(raw)
1050
+ @last_response_headers = {
1051
+ 'url' => url, 'http_code' => status, 'headers' => response_headers,
1052
+ 'total_time' => (monotonic - @start_at).round(6)
1053
+ }
1054
+ decoded = Php.json_decode(response_body)
1055
+ @last_response = Php.array?(decoded) ? decoded : { 'raw_response' => response_body }
1056
+
1057
+ if status >= 200 && status <= 299
1058
+ parsed = decoded
1059
+ @last_parsed_response = parsed
1060
+ maybe_send_performance
1061
+ return parsed
1062
+ end
1063
+
1064
+ parsed = decoded
1065
+ if [301, 302].include?(status)
1066
+ parsed = { 'message' => 'Invalid API credentials', 'code' => 'CREDENTIALS',
1067
+ 'details' => 'Authentication failed. Check your API key and reseller ID.' }
1068
+ end
1069
+
1070
+ message = first_set(Php.get(parsed, 'message'), Php.get(parsed, 'error', 'message'), response_body)
1071
+ code = first_set(Php.get(parsed, 'code'), Php.get(parsed, 'error', 'code'), "HTTP_#{status}")
1072
+ api_details = first_set(Php.get(parsed, 'details'), Php.get(parsed, 'error', 'details'))
1073
+ details = first_set(api_details, response_body)
1074
+ # The gateway's own code is kept as GatewayCode next to API_<status>.
1075
+ if code.is_a?(String) && code != '' && !code.start_with?('HTTP_') && code != 'CREDENTIALS'
1076
+ @last_gateway_code = code
1077
+ end
1078
+
1079
+ if Php.trim(message) == ''
1080
+ message = "HTTP #{status} - #{http_status_reason(status)}"
1081
+ details = message if Php.trim(details) == ''
1082
+ end
1083
+
1084
+ if BOILERPLATE_RE.match?(Php.to_s(message)) || Php.to_s(message) == Php.to_s(code)
1085
+ # Only the gateway's details field may replace the message, never the raw body.
1086
+ clean = Php.trim(Php.to_s(api_details).sub(/\ADna\s+/, ''))
1087
+ message = if clean != '' && clean != Php.to_s(message) && clean != Php.to_s(code) && !BOILERPLATE_RE.match?(clean)
1088
+ clean
1089
+ else
1090
+ "The operation could not be completed by the registry (error: #{Php.to_s(code)}). " \
1091
+ 'Please try again or contact support.'
1092
+ end
1093
+ end
1094
+
1095
+ validation = first_set(Php.get(parsed, 'error', 'validationErrors'), Php.get(parsed, 'validationErrors'))
1096
+ if Php.array?(validation) && !validation.empty?
1097
+ flattened = flatten_validation_errors(validation)
1098
+ details = flattened unless flattened == ''
1099
+ end
1100
+
1101
+ @last_parsed_response = set_error(code, message, details)
1102
+ error = ApiError.new(Php.to_s(message), status)
1103
+ error.set_backtrace(caller)
1104
+ send_error_to_sentry_async(error)
1105
+ raise error
1106
+ end
1107
+
1108
+ def transport_failure(url, errno, detail)
1109
+ @transport_failed = true
1110
+ message = "Curl error during request: #{detail}"
1111
+ message = "Could not reach the DomainNameAPI service: the connection timed out. (#{detail})" if errno == 28
1112
+ @last_response_headers = { 'url' => url, 'http_code' => 0, 'headers' => {},
1113
+ 'total_time' => (monotonic - @start_at).round(6) }
1114
+ @last_response = { 'raw_response' => false }
1115
+ @last_parsed_response = set_error("CURL_#{errno}", message, message)
1116
+ error = ApiError.new(message, errno)
1117
+ error.set_backtrace(caller)
1118
+ send_error_to_sentry_async(error)
1119
+ raise error
1120
+ end
1121
+
1122
+ def unpack_response(raw)
1123
+ if raw.is_a?(Hash)
1124
+ status = raw[:status] || raw['status']
1125
+ body = raw[:body] || raw['body']
1126
+ headers = raw[:headers] || raw['headers'] || {}
1127
+ else
1128
+ status = raw.status
1129
+ body = raw.body
1130
+ headers = raw.respond_to?(:headers) ? raw.headers || {} : {}
1131
+ end
1132
+ [status.to_i, body.nil? ? '' : body.to_s, headers]
1133
+ end
1134
+
1135
+ def maybe_send_performance
1136
+ duration = (monotonic - @start_at) * 1000.0
1137
+ return unless duration > 1000 || rand(1..1000) <= PERFORMANCE_SAMPLE_RATE
1138
+
1139
+ send_performance_metrics_to_sentry(
1140
+ 'operation' => @last_function,
1141
+ 'duration' => duration,
1142
+ 'success' => true,
1143
+ 'timestamp' => Time.now.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ'),
1144
+ 'start_timestamp' => @start_wall.utc.strftime('%Y-%m-%dT%H:%M:%S.%LZ')
1145
+ )
1146
+ end
1147
+
1148
+ # $a ?? $b ?? $c
1149
+ def first_set(*values)
1150
+ values.find { |v| !v.nil? }
1151
+ end
1152
+
1153
+ def pace_zone_request(endpoint)
1154
+ return unless endpoint.to_s.sub(%r{\A/+}, '').start_with?('domains/zones')
1155
+
1156
+ if @last_zone_request_at.positive? && @zone_min_interval.positive?
1157
+ wait = @zone_min_interval - (monotonic - @last_zone_request_at)
1158
+ sleep(wait) if wait.positive?
1159
+ end
1160
+ @last_zone_request_at = monotonic
1161
+ end
1162
+
1163
+ def http_status_reason(status)
1164
+ return HTTP_REASONS[status] if HTTP_REASONS.key?(status)
1165
+ return 'Server Error' if status >= 500
1166
+ return 'Client Error' if status >= 400
1167
+
1168
+ 'Unexpected Response'
1169
+ end
1170
+
1171
+ # API_<status> for HTTP errors, CURL_<errno> for transport failures.
1172
+ def format_error_code(code)
1173
+ return Php.to_s(code) unless Php.numeric?(code)
1174
+
1175
+ "#{@transport_failed ? 'CURL_' : 'API_'}#{Php.to_s(code)}"
1176
+ end
1177
+
1178
+ def set_error(code, message = '', details = '')
1179
+ if code.is_a?(String) && DEFAULT_ERRORS.key?(code)
1180
+ default_message, default_details = DEFAULT_ERRORS[code]
1181
+ result = {
1182
+ 'Code' => code,
1183
+ 'Message' => Php.trim(message) != '' ? message : default_message,
1184
+ 'Details' => Php.trim(details) != '' ? details : default_details
1185
+ }
1186
+ else
1187
+ result = { 'Code' => code, 'Message' => message, 'Details' => details }
1188
+ end
1189
+ if result['Code'].is_a?(String) && result['Code'].match?(/\AAPI_\d+\z/) && !Php.empty?(@last_gateway_code) &&
1190
+ @last_gateway_code != ''
1191
+ result['GatewayCode'] = @last_gateway_code
1192
+ end
1193
+ result
1194
+ end
1195
+
1196
+ # register/transfer/renew can answer HTTP 200 with {"success": false}:
1197
+ # a refusal, returned as an error envelope (nil when it succeeded).
1198
+ def operation_failure(response, code)
1199
+ return nil unless response.is_a?(Hash) && response.key?('success') && response['success'] == false
1200
+
1201
+ message = Php.trim(Php.to_s(first_set(response['message'], '')))
1202
+ message = 'The registry did not accept the request.' if message == ''
1203
+ { 'result' => RESULT_ERROR, 'error' => set_error(code, message, message) }
1204
+ end
1205
+
1206
+ FORWARD_NOT_SET = 'redirect information not found'
1207
+
1208
+ # The gateway answers "Redirect information not found" when no forwarding is set.
1209
+ def forward_not_set?(error)
1210
+ error.message.downcase.include?(FORWARD_NOT_SET) ||
1211
+ Php.to_s(first_set(Php.get(@last_response, 'raw_response'), '')).downcase.include?(FORWARD_NOT_SET) ||
1212
+ Php.json_encode(@last_response.nil? ? [] : @last_response).downcase.include?(FORWARD_NOT_SET)
1213
+ rescue StandardError
1214
+ false
1215
+ end
1216
+
1217
+ # The catch block shared by every network method.
1218
+ def error_envelope(error, fallback)
1219
+ code = format_error_code(error.code)
1220
+ code = fallback if Php.empty?(code)
1221
+ details = Php.get(@last_parsed_response, 'Details')
1222
+ details = Php.get(@last_response, 'raw_response') if details.nil?
1223
+ details = error.message if details.nil?
1224
+ { 'result' => RESULT_ERROR, 'error' => set_error(code, error.message, details) }
1225
+ end
1226
+
1227
+ # array_map() keeping the container type.
1228
+ def php_map(collection, &block)
1229
+ case collection
1230
+ when Hash then collection.transform_values(&block)
1231
+ when Array then collection.map(&block)
1232
+ else []
1233
+ end
1234
+ end
1235
+
1236
+ # --- response parsing ------------------------------------------------------
1237
+
1238
+ def list_item(item)
1239
+ status_code = Php.get(item, 'statusCode')
1240
+ status_text = Php.get(item, 'statusText')
1241
+ status = if !status_code.nil? && status_code != '' && !Php.numeric?(status_code)
1242
+ Php.to_s(status_code)
1243
+ elsif !status_text.nil? && status_text != '' && !Php.numeric?(status_text)
1244
+ Php.to_s(status_text)
1245
+ else
1246
+ normalize_domain_status(first_set(Php.get(item, 'status'), ''))
1247
+ end
1248
+ ns = Php.get(item, 'nameServers')
1249
+ {
1250
+ 'ID' => Php.to_i(first_set(Php.get(item, 'id'), 0)),
1251
+ 'Status' => status,
1252
+ 'DomainName' => first_set(Php.get(item, 'domainName'), ''),
1253
+ 'AuthCode' => first_set(Php.get(item, 'authCode'), ''),
1254
+ 'LockStatus' => Php.truthy?(Php.get(item, 'lockStatus')) ? 'true' : 'false',
1255
+ 'PrivacyProtectionStatus' => Php.truthy?(Php.get(item, 'privacyProtectionStatus')) ? 'true' : 'false',
1256
+ 'IsChildNameServer' => Php.truthy?(Php.get(item, 'hosts')) ? 'true' : 'false',
1257
+ 'Contacts' => {
1258
+ 'Billing' => { 'ID' => '' },
1259
+ 'Technical' => { 'ID' => '' },
1260
+ 'Administrative' => { 'ID' => '' },
1261
+ 'Registrant' => { 'ID' => '' }
1262
+ },
1263
+ 'Dates' => {
1264
+ 'Start' => format_api_date(Php.get(item, 'startDate')),
1265
+ 'Expiration' => format_api_date(Php.get(item, 'expirationDate')),
1266
+ 'RemainingDays' => Php.to_i(first_set(Php.get(item, 'remainingDay'), 0))
1267
+ },
1268
+ 'NameServers' => ns.nil? ? [] : ns,
1269
+ 'Additional' => {},
1270
+ 'ChildNameServers' => []
1271
+ }
1272
+ end
1273
+
1274
+ def add_price(pricing, currencies, out_type, info)
1275
+ period = Php.to_i(first_set(Php.get(info, 'period'), 1))
1276
+ period = 1 if period < 1
1277
+ price = Php.get(info, 'price')
1278
+ (pricing[out_type] ||= {})[period] = price.nil? ? '0.0000' : Php.number_format(price, 4)
1279
+ currencies[out_type] = first_set(Php.get(info, 'currency'), '')
1280
+ end
1281
+
1282
+ def normalize_domain_status(value)
1283
+ if Php.numeric?(value)
1284
+ DOMAIN_STATUS_MAP.fetch(Php.to_i(value)) { Php.to_s(value) }
1285
+ else
1286
+ Php.to_s(value)
1287
+ end
1288
+ end
1289
+
1290
+ def parse_domain_info(data)
1291
+ return { 'result' => RESULT_ERROR, 'error' => set_error('RESPONSE') } if Php.empty?(data)
1292
+
1293
+ remaining = Php.get(data, 'remainingDay')
1294
+ nameservers = Php.get(data, 'nameservers')
1295
+ additional = Php.get(data, 'additionalAttributes')
1296
+ hosts = Php.get(data, 'hosts')
1297
+ {
1298
+ 'data' => {
1299
+ 'ID' => Php.to_i(first_set(Php.get(data, 'id'), 0)),
1300
+ 'Status' => normalize_domain_status(first_set(Php.get(data, 'status'), '')),
1301
+ 'DomainName' => Php.to_s(first_set(Php.get(data, 'domainName'), Php.get(data, 'name'), '')),
1302
+ 'AuthCode' => Php.to_s(first_set(Php.get(data, 'authCode'), Php.get(data, 'eppCode'), '')),
1303
+ 'LockStatus' => Php.truthy?(Php.get(data, 'lockStatus')) ? 'true' : 'false',
1304
+ 'PrivacyProtectionStatus' => Php.truthy?(Php.get(data, 'privacyProtectionStatus')) ? 'true' : 'false',
1305
+ 'IsChildNameServer' => Php.truthy?(hosts) ? 'true' : 'false',
1306
+ 'Contacts' => parse_contact_ids(first_set(Php.get(data, 'contacts'), [])),
1307
+ 'Dates' => {
1308
+ 'Start' => format_api_date(Php.get(data, 'startDate')),
1309
+ 'Expiration' => format_api_date(Php.get(data, 'expirationDate')),
1310
+ 'RemainingDays' => if remaining.nil?
1311
+ remaining_days_from_date(Php.get(data, 'expirationDate'))
1312
+ else
1313
+ Php.to_i(remaining)
1314
+ end
1315
+ },
1316
+ 'NameServers' => nameservers.nil? ? [] : php_map(nameservers) { |v| Php.to_s(v) },
1317
+ 'Additional' => additional.nil? ? {} : Php.to_array(additional),
1318
+ 'ChildNameServers' => hosts.nil? ? [] : php_map(hosts) { |ns| child_name_server(ns) }
1319
+ },
1320
+ 'result' => RESULT_OK
1321
+ }
1322
+ end
1323
+
1324
+ def child_name_server(host)
1325
+ ips = Php.values(first_set(Php.get(host, 'ipAddresses'), [])).map { |ip| Php.get(ip, 'ipAddress') }
1326
+ { 'ns' => Php.get(host, 'name'), 'ip' => ips.empty? ? '' : ips.last }
1327
+ end
1328
+
1329
+ def parse_contact_ids(contacts)
1330
+ result = {
1331
+ 'Billing' => { 'ID' => 0 },
1332
+ 'Technical' => { 'ID' => 0 },
1333
+ 'Administrative' => { 'ID' => 0 },
1334
+ 'Registrant' => { 'ID' => 0 }
1335
+ }
1336
+ return result unless Php.array?(contacts)
1337
+
1338
+ Php.values(contacts).each do |c|
1339
+ api_type = first_set(Php.get(c, 'ContactType'), Php.get(c, 'contactType'), '')
1340
+ key = api_type.is_a?(String) ? CONTACT_TYPE_IN[api_type] : nil
1341
+ result[key]['ID'] = first_set(Php.get(c, 'handle'), Php.get(c, 'id'), 0) if key
1342
+ end
1343
+ result
1344
+ end
1345
+
1346
+ def parse_contact_info(data)
1347
+ return [] if Php.empty?(data)
1348
+
1349
+ g = ->(*keys) { first_set(*keys.map { |k| Php.get(data, k) }, '') }
1350
+ {
1351
+ 'ID' => g.call('handle', 'id'),
1352
+ 'Status' => first_set(Php.get(data, 'status'), 'Active'),
1353
+ 'AuthCode' => g.call('authCode'),
1354
+ 'FirstName' => g.call('firstName'),
1355
+ 'LastName' => g.call('lastName'),
1356
+ 'Company' => g.call('companyName', 'organizationName', 'company'),
1357
+ 'EMail' => g.call('eMail', 'emailAddress', 'email'),
1358
+ 'Type' => g.call('type'),
1359
+ 'Address' => {
1360
+ 'Line1' => g.call('address', 'addressLine1'),
1361
+ 'Line2' => g.call('addressLine2'),
1362
+ 'Line3' => g.call('addressLine3'),
1363
+ 'State' => g.call('state', 'stateOrProvince'),
1364
+ 'City' => g.call('city'),
1365
+ 'Country' => g.call('country', 'countryCode'),
1366
+ 'ZipCode' => g.call('postalCode', 'zipCode')
1367
+ },
1368
+ 'Phone' => {
1369
+ 'Phone' => { 'Number' => g.call('phone', 'phoneNumber'), 'CountryCode' => g.call('phoneCountryCode') },
1370
+ 'Fax' => { 'Number' => g.call('fax', 'faxNumber'), 'CountryCode' => g.call('faxCountryCode') }
1371
+ },
1372
+ 'Additional' => first_set(Php.get(data, 'additionalAttributes'), {})
1373
+ }
1374
+ end
1375
+
1376
+ # Build a gateway contact (ContactLiteDto) from the flat or nested shape.
1377
+ def parse_contact(contact, type)
1378
+ api_type = CONTACT_TYPE_OUT[type] || type
1379
+
1380
+ address = first_set(Php.get(contact, 'AddressLine1'), Php.get(contact, 'Address', 'Line1'), '')
1381
+ line2 = first_set(Php.get(contact, 'AddressLine2'), Php.get(contact, 'Address', 'Line2'), '')
1382
+ address = "#{Php.to_s(address)} #{Php.to_s(line2)}" unless Php.empty?(line2)
1383
+
1384
+ phone = first_set(Php.get(contact, 'Phone'), Php.get(contact, 'Phone', 'Phone', 'Number'), '')
1385
+ phone_cc = first_set(Php.get(contact, 'PhoneCountryCode'), Php.get(contact, 'Phone', 'Phone', 'CountryCode'), '')
1386
+ fax = first_set(Php.get(contact, 'Fax'), Php.get(contact, 'Phone', 'Fax', 'Number'), '')
1387
+ fax_cc = first_set(Php.get(contact, 'FaxCountryCode'), Php.get(contact, 'Phone', 'Fax', 'CountryCode'), '')
1388
+
1389
+ if Php.array?(phone)
1390
+ phone_cc = first_set(Php.get(phone, 'Phone', 'CountryCode'), '')
1391
+ phone = first_set(Php.get(phone, 'Phone', 'Number'), '')
1392
+ end
1393
+ if Php.array?(fax)
1394
+ fax_cc = first_set(Php.get(fax, 'Fax', 'CountryCode'), '')
1395
+ fax = first_set(Php.get(fax, 'Fax', 'Number'), '')
1396
+ end
1397
+
1398
+ validated = validate_contact(
1399
+ 'FirstName' => first_set(Php.get(contact, 'FirstName'), ''),
1400
+ 'LastName' => first_set(Php.get(contact, 'LastName'), ''),
1401
+ 'AddressLine1' => address,
1402
+ 'City' => first_set(Php.get(contact, 'City'), Php.get(contact, 'Address', 'City'), ''),
1403
+ 'State' => first_set(Php.get(contact, 'State'), Php.get(contact, 'Address', 'State'), ''),
1404
+ 'Country' => first_set(Php.get(contact, 'Country'), Php.get(contact, 'Address', 'Country'), ''),
1405
+ 'ZipCode' => first_set(Php.get(contact, 'ZipCode'), Php.get(contact, 'Address', 'ZipCode'), ''),
1406
+ 'Phone' => phone,
1407
+ 'PhoneCountryCode' => phone_cc,
1408
+ 'Fax' => fax,
1409
+ 'FaxCountryCode' => fax_cc
1410
+ )
1411
+
1412
+ {
1413
+ 'contactType' => api_type,
1414
+ 'firstName' => validated['FirstName'],
1415
+ 'lastName' => validated['LastName'],
1416
+ 'companyName' => first_set(Php.get(contact, 'Company'), ''),
1417
+ 'eMail' => first_set(Php.get(contact, 'EMail'), ''),
1418
+ 'address' => validated['AddressLine1'],
1419
+ 'city' => validated['City'],
1420
+ 'state' => validated['State'],
1421
+ 'country' => validated['Country'],
1422
+ 'postalCode' => validated['ZipCode'],
1423
+ 'phoneCountryCode' => validated['PhoneCountryCode'],
1424
+ 'phone' => validated['Phone'],
1425
+ 'faxCountryCode' => validated['FaxCountryCode'],
1426
+ 'fax' => validated['Fax'],
1427
+ 'isHidden' => Php.truthy?(first_set(Php.get(contact, 'IsHidden'), false))
1428
+ }
1429
+ end
1430
+
1431
+ def sanitize_person_name(name)
1432
+ str = Php.to_s(name)
1433
+ clean = if str.dup.force_encoding(Encoding::UTF_8).valid_encoding?
1434
+ str.dup.force_encoding(Encoding::UTF_8).gsub(/[^\p{L}[[:space:]]'\-]/, '')
1435
+ else
1436
+ str.b.gsub(/[^a-zA-Z\s'\-]/n, '').force_encoding(Encoding::UTF_8)
1437
+ end
1438
+ Php.trim(clean.gsub(/[[:space:]]+/, ' '))
1439
+ end
1440
+
1441
+ COUNTRY_NAMES = {
1442
+ 'turkey' => 'TR', 'türkiye' => 'TR', 'turkiye' => 'TR',
1443
+ 'united states' => 'US', 'united states of america' => 'US', 'usa' => 'US',
1444
+ 'united kingdom' => 'GB', 'great britain' => 'GB', 'england' => 'GB',
1445
+ 'germany' => 'DE', 'deutschland' => 'DE', 'france' => 'FR', 'spain' => 'ES',
1446
+ 'italy' => 'IT', 'netherlands' => 'NL', 'india' => 'IN', 'china' => 'CN',
1447
+ 'russia' => 'RU', 'canada' => 'CA', 'australia' => 'AU', 'brazil' => 'BR'
1448
+ }.freeze
1449
+
1450
+ def normalize_country_code(country)
1451
+ country = Php.trim(country)
1452
+ return '' if country == ''
1453
+ return Php.upper(country) if country.bytesize == 2
1454
+
1455
+ COUNTRY_NAMES.fetch(Php.lower(country)) { country }
1456
+ end
1457
+
1458
+ TWO_DIGIT_CALLING_CODES = %w[20 27 30 31 32 33 34 36 39 40 41 43 44 45 46 47 48 49 51 52 53 54 55 56 57 58
1459
+ 60 61 62 63 64 65 66 81 82 84 86 90 91 92 93 94 95 98].freeze
1460
+
1461
+ # E.164 calling code at the start of an international number (digits only).
1462
+ def calling_code_prefix(digits)
1463
+ return '' if digits == '' || digits[0] == '0'
1464
+ return digits[0] if %w[1 7].include?(digits[0])
1465
+ return digits[0, 2] if TWO_DIGIT_CALLING_CODES.include?(digits[0, 2])
1466
+
1467
+ digits.length >= 3 ? digits[0, 3] : ''
1468
+ end
1469
+
1470
+ # Split a free-form phone number into [subscriber number, country code].
1471
+ def normalize_phone(number, country_code)
1472
+ number_s = Php.to_s(number).b
1473
+ intl = number_s.match?(/\A\s*(\+|00)/n)
1474
+ digits = number_s.gsub(/\D/n, '')
1475
+ cc = Php.to_s(country_code).b.gsub(/\D/n, '')
1476
+
1477
+ return ['', cc] if digits == ''
1478
+
1479
+ if intl
1480
+ digits = digits[2..] if digits.start_with?('00')
1481
+ if cc != '' && digits.start_with?(cc) && digits.length > cc.length
1482
+ digits = digits[cc.length..]
1483
+ else
1484
+ # The number carries its own calling code and it differs from the given one.
1485
+ own = calling_code_prefix(digits)
1486
+ if own != '' && digits.length > own.length
1487
+ cc = own
1488
+ digits = digits[own.length..]
1489
+ end
1490
+ end
1491
+ elsif cc != '' && digits.start_with?(cc) && digits.length > 10
1492
+ digits = digits[cc.length..]
1493
+ end
1494
+
1495
+ if cc == ''
1496
+ digits = digits[2..] if digits.length == 12 && digits.start_with?('90')
1497
+ cc = '90'
1498
+ end
1499
+
1500
+ digits = digits.sub(/\A0+/, '') if digits.length > 1 && digits[0] == '0'
1501
+
1502
+ [digits.dup.force_encoding(Encoding::UTF_8), cc.dup.force_encoding(Encoding::UTF_8)]
1503
+ end
1504
+
1505
+ def normalize_host_name(host)
1506
+ Php.lower(Php.rtrim(Php.trim(host), '.'))
1507
+ end
1508
+
1509
+ def normalize_host_name_list(hosts)
1510
+ return [] unless Php.array?(hosts)
1511
+
1512
+ out = []
1513
+ Php.values(hosts).each do |host|
1514
+ next unless host.is_a?(String)
1515
+
1516
+ host = normalize_host_name(host)
1517
+ out << host if host != '' && !out.include?(host)
1518
+ end
1519
+ out
1520
+ end
1521
+
1522
+ TR_KEY_MAP = {
1523
+ 'TRABISDOMAINCATEGORY' => 'TRABISDOMAINCATEGORY',
1524
+ 'TRABISCITIZENID' => 'TRABISCITIZENID',
1525
+ 'TRABISCITIZIENID' => 'TRABISCITIZENID',
1526
+ 'TRABISTAXOFFICE' => 'TRABISTAXOFFICE',
1527
+ 'TRABISTAXNUMBER' => 'TRABISTAXNUMBER'
1528
+ }.freeze
1529
+
1530
+ def normalize_tr_attributes(attrs)
1531
+ return attrs if attrs.empty?
1532
+
1533
+ normalized = {}
1534
+ attrs.each do |key, value|
1535
+ next unless key.is_a?(String) && TR_KEY_MAP.key?(key)
1536
+ next if key == 'TRABISCITIZIENID' && normalized.key?('TRABISCITIZENID')
1537
+
1538
+ normalized[TR_KEY_MAP[key]] = value
1539
+ end
1540
+
1541
+ unless normalized['TRABISDOMAINCATEGORY'].nil?
1542
+ cat = Php.lower(Php.trim(normalized['TRABISDOMAINCATEGORY']))
1543
+ if %w[1 owner personal individual].include?(cat)
1544
+ normalized['TRABISDOMAINCATEGORY'] = 'Owner'
1545
+ elsif %w[0 corporate company].include?(cat)
1546
+ normalized['TRABISDOMAINCATEGORY'] = 'Corporate'
1547
+ end
1548
+ end
1549
+ normalized
1550
+ end
1551
+
1552
+ def stringify_attributes(attrs)
1553
+ attrs.each_with_object({}) do |(key, value), out|
1554
+ out[key.to_s] = case value
1555
+ when true then 'true'
1556
+ when false then 'false'
1557
+ when nil then ''
1558
+ when String, Integer, Float then Php.to_s(value)
1559
+ else Php.json_encode(value)
1560
+ end
1561
+ end
1562
+ end
1563
+
1564
+ def flatten_validation_errors(errors)
1565
+ parts = []
1566
+ Php.values(errors).each do |ve|
1567
+ if ve.is_a?(String)
1568
+ parts << Php.trim(ve) if Php.trim(ve) != ''
1569
+ next
1570
+ end
1571
+ next unless Php.array?(ve)
1572
+
1573
+ message = Php.isset?(ve, 'message') ? Php.trim(Php.to_s(Php.get(ve, 'message'))) : ''
1574
+ members = []
1575
+ member_list = Php.get(ve, 'members')
1576
+ if Php.array?(member_list)
1577
+ Php.values(member_list).each do |member|
1578
+ label = humanize_validation_member(Php.to_s(member))
1579
+ members << label if label != ''
1580
+ end
1581
+ end
1582
+
1583
+ if !members.empty? && message != ''
1584
+ parts << "#{members.join(', ')} - #{message}"
1585
+ elsif !members.empty?
1586
+ parts << members.join(', ')
1587
+ elsif message != ''
1588
+ parts << message
1589
+ end
1590
+ end
1591
+ parts.uniq.join(' | ')
1592
+ end
1593
+
1594
+ VALIDATION_ROLES = {
1595
+ 'administrativecontact' => 'Admin', 'administrative' => 'Admin',
1596
+ 'technicalcontact' => 'Tech', 'technical' => 'Tech',
1597
+ 'registrantcontact' => 'Registrant', 'registrant' => 'Registrant',
1598
+ 'billingcontact' => 'Billing', 'billing' => 'Billing'
1599
+ }.freeze
1600
+
1601
+ def humanize_validation_member(member)
1602
+ member = Php.trim(member)
1603
+ return '' if member == ''
1604
+
1605
+ member = Php.ltrim(member, '$.')
1606
+ member = member.gsub(/\[\d+\]/, '')
1607
+ segments = member.split(%r{[./]+}).reject(&:empty?)
1608
+ return '' if segments.empty?
1609
+
1610
+ segments.map { |seg| VALIDATION_ROLES[Php.lower(seg)] || Php.ucfirst(seg) }.join(' ').then { |s| Php.trim(s) }
1611
+ end
1612
+
1613
+ # --- dates -----------------------------------------------------------------
1614
+
1615
+ ISO_DATE_RE = /\A\s*(-?\d{4,})-(\d{1,2})-(\d{1,2})(?:[T\s]+(\d{1,2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?\s*(Z|[+-]\d{2}:?\d{2}|[+-]\d{2})?\s*\z/i.freeze
1616
+
1617
+ # strtotime() for the date shapes the gateway sends; nil when unparseable.
1618
+ def parse_api_time(value)
1619
+ return nil if value.nil? || value == ''
1620
+
1621
+ str = Php.to_s(value)
1622
+ if (m = ISO_DATE_RE.match(str))
1623
+ year, month, day = m[1].to_i, m[2].to_i, m[3].to_i
1624
+ hour, min, sec = m[4].to_i, m[5].to_i, m[6].to_i
1625
+ return nil if month < 1 || month > 12 || day < 1 || day > 31 || hour > 24 || min > 59 || sec > 59
1626
+
1627
+ time = Time.utc(year, month, day, hour, min, sec)
1628
+ if m[8] && m[8].upcase != 'Z'
1629
+ sign = m[8].start_with?('-') ? -1 : 1
1630
+ digits = m[8].delete(':+-')
1631
+ offset = (digits[0, 2].to_i * 3600) + (digits[2, 2].to_i * 60)
1632
+ time -= sign * offset
1633
+ end
1634
+ return time
1635
+ end
1636
+ return nil unless str.match?(/\d/)
1637
+
1638
+ Time.parse(str).utc
1639
+ rescue ArgumentError, RangeError
1640
+ nil
1641
+ end
1642
+
1643
+ def format_api_date(value)
1644
+ time = parse_api_time(value)
1645
+ time.nil? ? '' : time.strftime('%Y-%m-%dT%H:%M:%S')
1646
+ end
1647
+
1648
+ def remaining_days_from_date(value)
1649
+ time = parse_api_time(value)
1650
+ return 0 if time.nil?
1651
+
1652
+ [0, ((time - Time.now) / 86_400.0).ceil].max
1653
+ end
1654
+
1655
+ # --- zone helpers ------------------------------------------------------------
1656
+
1657
+ def unsupported_zone_type(type)
1658
+ type = Php.upper(Php.trim(type))
1659
+ return 'Record type is required.' if type == ''
1660
+ return nil if ZONE_RECORD_TYPES.include?(type)
1661
+
1662
+ "Record type \"#{type}\" is not supported by the DNS zone API. Supported types: #{ZONE_RECORD_TYPES.join(', ')}."
1663
+ end
1664
+
1665
+ def relativize_record_name(name, domain_name)
1666
+ name = Php.trim(name)
1667
+ domain = Php.rtrim(Php.lower(Php.trim(domain_name)), '.')
1668
+ return '' if name == '' || name == '@'
1669
+
1670
+ bare = Php.rtrim(name, '.')
1671
+ return '' if Php.casecmp_eq?(bare, domain)
1672
+
1673
+ suffix = ".#{domain}"
1674
+ if bare.bytesize > suffix.bytesize && Php.casecmp_eq?(bare.b[-suffix.bytesize..], suffix.b)
1675
+ return bare.b[0, bare.bytesize - suffix.bytesize].force_encoding(bare.encoding)
1676
+ end
1677
+
1678
+ bare
1679
+ end
1680
+
1681
+ def qualify_record_name(name, domain_name)
1682
+ name = Php.trim(name)
1683
+ domain = Php.rtrim(Php.lower(Php.trim(domain_name)), '.')
1684
+ apex = "#{domain}."
1685
+ return apex if name == '' || name == '@'
1686
+
1687
+ bare = Php.rtrim(name, '.')
1688
+ return "#{bare}." if Php.casecmp_eq?(bare, domain) || Php.lower(bare).end_with?(".#{domain}")
1689
+
1690
+ "#{bare}.#{apex}"
1691
+ end
1692
+
1693
+ def build_zone_struct(name, type, content, ttl)
1694
+ ttl = Php.to_i(ttl)
1695
+ ttl = 3600 if ttl < 1
1696
+ ttl = 86_400 if ttl > 86_400
1697
+ {
1698
+ 'name' => Php.to_s(name),
1699
+ 'ttl' => ttl,
1700
+ 'type' => Php.upper(type),
1701
+ 'contents' => Php.values(Php.to_array(content)).map { |v| Php.to_s(v) }
1702
+ }
1703
+ end
1704
+
1705
+ def read_resource_record_set(domain_name, name, type)
1706
+ current = get_resource_records(domain_name)
1707
+ return current if current['result'] != RESULT_OK
1708
+
1709
+ fqdn = qualify_record_name(name, domain_name)
1710
+ type = Php.upper(Php.trim(type))
1711
+ contents = []
1712
+ ttl = 0
1713
+ current['data']['records'].each do |rec|
1714
+ next unless Php.casecmp_eq?(rec['Name'], fqdn) && Php.casecmp_eq?(rec['Type'], type)
1715
+
1716
+ contents << Php.to_s(rec['Content']) if Php.to_s(rec['Content']) != ''
1717
+ ttl = Php.to_i(rec['TTL'])
1718
+ end
1719
+ { 'ttl' => ttl, 'contents' => contents }
1720
+ end
1721
+
1722
+ def fallback_ttl(set)
1723
+ Php.truthy?(set['ttl']) ? set['ttl'] : 3600
1724
+ end
1725
+
1726
+ def same_record_value?(type, a, b)
1727
+ normalize = lambda do |v|
1728
+ v = Php.trim(Php.to_s(v))
1729
+ if Php.upper(type) == 'TXT'
1730
+ v = v[1...-1] if v.length > 1 && v.start_with?('"') && v.end_with?('"')
1731
+ v
1732
+ else
1733
+ Php.lower(Php.rtrim(v, '.'))
1734
+ end
1735
+ end
1736
+ normalize.call(a) == normalize.call(b)
1737
+ end
1738
+
1739
+ def transfer_action(domain_name, action, default_status, fallback)
1740
+ response = request('POST', "domains/transfers/#{action}", { 'domainName' => domain_name })
1741
+ status = Php.get(response, 'status')
1742
+ { 'result' => RESULT_OK, 'data' => { 'DomainName' => domain_name, 'Status' => status.nil? ? default_status : status } }
1743
+ rescue ApiError => e
1744
+ error_envelope(e, fallback)
1745
+ end
1746
+ end
1747
+ end