domainnameapi 1.0.0__py3-none-any.whl

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