Repository: getmoto/moto
Pull request: Feature: Route53Domains
Adding support for Route53Domains endpoints

This PR is a work in progress



Problem statement:
Feature Request: Route53Domains
Hello! I've recently started using Moto in a personal project of mine and it has really eased my development with AWS! 

In my project I am using the Route53Domains service and was wondering if there are any plans on adding support for it? Would you be interested for me to try and open a PR for this change?

Hints from issue discussion:
Hi @NeevCohen, welcome to Moto!

PR's are always welcome. We have some documentation here that should get you started: 
http://docs.getmoto.org/en/latest/docs/contributing/index.html

Feel free to let us know if you run into any issues though.

Out of interest, which methods/features would you like to mock?
@bblommers Thanks!

I think I'll start with the basic ones - 
* `register_domain`
* `get_domain_detail`
* `list_domains`
* `delete_domain`
* `get_operation_detail`
* `list_operations`
* `update_domain_nameservers`

Would these be OK for now?

Another thing, would you be open to adding `pydantic` as a dependency? I think it could be really useful in validating input parameters.
> I think I'll start with the basic ones -

That's absolutely fine @NeevCohen. 

> Another thing, would you be open to adding Pydantic as a dependency?

I don't think that's a good fit for Moto honestly. We're trying to keep the project as low-profile as possible, so the least amount of dependencies. 

A lot of validation also already happens within the SDK themselves, so that's already taken care of. The additional validation that needs to be done in Moto is currently done manually (regular' old if-statements), and I don't think that it is (complex) enough to warrant a dedicated library.
@bblommers I started working on this a bit and it might take me a little while. Is it OK for me to open a WIP PR that you can take a look at to make sure I'm in the right direction?
> @bblommers I started working on this a bit and it might take me a little while. Is it OK for me to open a WIP PR that you can take a look at to make sure I'm in the right direction?

Absolutely! That makes a lot of sense @NeevCohen 
@bblommers Cool! Once I'll have a feature or two ready I'll open the PR. Thanks!

Commit message: Apply formatting

Patch to review:
diff --git a/moto/backend_index.py b/moto/backend_index.py
index c413f928852b..5e0d4455b81e 100644
--- a/moto/backend_index.py
+++ b/moto/backend_index.py
@@ -142,6 +142,10 @@
         "route53resolver",
         re.compile("https?://route53resolver\\.(.+)\\.amazonaws\\.com"),
     ),
+    (
+        "route53domains",
+        re.compile("https?://route53domains\\.(.+)\\.amazonaws\\.com"),
+    ),
     ("s3", re.compile("https?://s3(?!-control)(.*)\\.amazonaws.com")),
     (
         "s3",
diff --git a/moto/backends.py b/moto/backends.py
index 14ba4ad92193..e7d636f1e245 100644
--- a/moto/backends.py
+++ b/moto/backends.py
@@ -108,6 +108,7 @@
     from moto.resourcegroupstaggingapi.models import ResourceGroupsTaggingAPIBackend
     from moto.robomaker.models import RoboMakerBackend
     from moto.route53.models import Route53Backend
+    from moto.route53domains.models import Route53DomainsBackend
     from moto.route53resolver.models import Route53ResolverBackend
     from moto.s3.models import S3Backend
     from moto.s3control.models import S3ControlBackend
@@ -266,6 +267,7 @@ def get_service_from_url(url: str) -> Optional[str]:
     "Literal['robomaker']",
     "Literal['route53']",
     "Literal['route53resolver']",
+    "Literal['route53domains']",
     "Literal['s3']",
     "Literal['s3bucket_path']",
     "Literal['s3control']",
@@ -506,6 +508,8 @@ def get_backend(name: "Literal['route53']") -> "BackendDict[Route53Backend]": ..
 @overload
 def get_backend(name: "Literal['route53resolver']") -> "BackendDict[Route53ResolverBackend]": ...
 @overload
+def get_backend(name: "Literal['route53domains']") -> "BackendDict[Route53DomainsBackend]": ...
+@overload
 def get_backend(name: "Literal['s3']") -> "BackendDict[S3Backend]": ...
 @overload
 def get_backend(name: "Literal['s3bucket_path']") -> "BackendDict[S3Backend]": ...
diff --git a/moto/core/responses.py b/moto/core/responses.py
index 6b6306d2b0d9..b80f5ca04ee3 100644
--- a/moto/core/responses.py
+++ b/moto/core/responses.py
@@ -619,9 +619,7 @@ def _get_param(self, param_name: str, if_none: Any = None) -> Any:
         if self.body is not None:
             try:
                 return json.loads(self.body)[param_name]
-            except ValueError:
-                pass
-            except KeyError:
+            except (ValueError, KeyError):
                 pass
         # try to get path parameter
         if self.uri_match:
diff --git a/moto/route53domains/__init__.py b/moto/route53domains/__init__.py
new file mode 100644
index 000000000000..132e149020a3
--- /dev/null
+++ b/moto/route53domains/__init__.py
@@ -0,0 +1 @@
+from .models import route53domains_backends  # noqa: F401
diff --git a/moto/route53domains/exceptions.py b/moto/route53domains/exceptions.py
new file mode 100644
index 000000000000..f09d46a9638c
--- /dev/null
+++ b/moto/route53domains/exceptions.py
@@ -0,0 +1,52 @@
+from typing import List
+
+from moto.core.exceptions import JsonRESTError
+
+
+class DomainLimitExceededException(JsonRESTError):
+    code = 400
+
+    def __init__(self):
+        super().__init__(
+            "DomainLimitExceeded",
+            "The number of domains has exceeded the allowed threshold for the account.",
+        )
+
+
+class DuplicateRequestException(JsonRESTError):
+    code = 400
+
+    def __init__(self):
+        super().__init__(
+            "DuplicateRequest", "The request is already in progress for the domain."
+        )
+
+
+class InvalidInputException(JsonRESTError):
+    code = 400
+
+    def __init__(self, error_msgs: List[str]):
+        error_msgs_str = "\n\t".join(error_msgs)
+        super().__init__(
+            "InvalidInput", f"The requested item is not acceptable.\n\t{error_msgs_str}"
+        )
+
+
+class OperationLimitExceededException(JsonRESTError):
+    code = 400
+
+    def __init__(self):
+        super().__init__(
+            "OperationLimitExceeded",
+            "The top-level domain does not support this operation.",
+        )
+
+
+class UnsupportedTLDException(JsonRESTError):
+    code = 400
+
+    def __init__(self, tld: str):
+        super().__init__(
+            "UnsupportedTLD",
+            f"Amazon Route53 does not support the top-level domain (TLD) `.{tld}`.",
+        )
diff --git a/moto/route53domains/models.py b/moto/route53domains/models.py
new file mode 100644
index 000000000000..5ff9dfb32bfe
--- /dev/null
+++ b/moto/route53domains/models.py
@@ -0,0 +1,161 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict, List, Tuple
+
+from moto.core.base_backend import BackendDict, BaseBackend
+from moto.route53 import route53_backends
+from moto.route53.models import Route53Backend
+
+from .exceptions import DuplicateRequestException, InvalidInputException
+from .validators import (
+    DOMAIN_OPERATION_STATUSES,
+    DOMAIN_OPERATION_TYPES,
+    Route53Domain,
+    Route53DomainsContactDetail,
+    Route53DomainsOperation,
+    ValidationException,
+)
+
+
+class Route53DomainsBackend(BaseBackend):
+    """Implementation of Route53Domains APIs."""
+
+    def __init__(self, region_name: str, account_id: str):
+        super().__init__(region_name, account_id)
+        self.__route53_backend: Route53Backend = route53_backends[account_id]["global"]
+        self.__domains: Dict[str, Route53Domain] = {}
+        self.__operations: Dict[str, Route53DomainsOperation] = {}
+
+    def register_domain(
+        self,
+        domain_name: str,
+        duration_in_years: int,
+        auto_renew: bool,
+        admin_contact: Dict,
+        registrant_contact: Dict,
+        tech_contact: Dict,
+        private_protect_admin_contact: bool,
+        private_protect_registrant_contact: bool,
+        private_protect_tech_contact: bool,
+        extra_params: List[Dict],
+    ) -> Route53DomainsOperation:
+        """Register a domain"""
+        requested_operation = Route53DomainsOperation.validate(
+            domain_name=domain_name, status="SUCCESSFUL", type_="REGISTER_DOMAIN"
+        )
+
+        self.__validate_duplicate_requests(requested_operation)
+
+        expiration_date = datetime.now(timezone.utc) + timedelta(
+            days=365 * duration_in_years
+        )
+
+        try:
+
+            domain = Route53Domain.validate(
+                domain_name=domain_name,
+                auto_renew=auto_renew,
+                admin_contact=Route53DomainsContactDetail.validate_dict(admin_contact),
+                registrant_contact=Route53DomainsContactDetail.validate_dict(
+                    registrant_contact
+                ),
+                tech_contact=Route53DomainsContactDetail.validate_dict(tech_contact),
+                admin_privacy=private_protect_admin_contact,
+                registrant_privacy=private_protect_registrant_contact,
+                tech_privacy=private_protect_tech_contact,
+                expiration_date=expiration_date,
+                extra_params=extra_params,
+            )
+
+        except ValidationException as e:
+            raise InvalidInputException(e.errors)
+
+        self.__operations[requested_operation.id] = requested_operation
+
+        self.__route53_backend.create_hosted_zone(
+            name=domain.domain_name, private_zone=False
+        )
+
+        self.__domains[domain_name] = domain
+        return requested_operation
+
+    def __validate_duplicate_requests(
+        self, requested_operation: Route53DomainsOperation
+    ):
+        for operation in self.__operations.values():
+            if (
+                operation.domain_name == requested_operation.domain_name
+                and operation.type == requested_operation.type
+            ):
+                raise DuplicateRequestException()
+
+    def list_operations(
+        self,
+        submitted_since_timestamp: int | None = None,
+        marker: str | None = None,
+        max_items: int | None = None,
+        statuses: List[str] | None = None,
+        types: List[str] | None = None,
+        sort_by: str | None = None,
+        sort_order: str | None = None,
+    ) -> Tuple[List[Route53DomainsOperation], str | None]:
+
+        errors = []
+        statuses = statuses or []
+        types = types or []
+        max_items = max_items or 20  # AWS default is 20
+
+        if any(status not in DOMAIN_OPERATION_STATUSES for status in statuses):
+            errors.append("Status is invalid")
+        if any(type_ not in DOMAIN_OPERATION_TYPES for type_ in types):
+            errors.append("Type is invalid")
+
+        if errors:
+            raise InvalidInputException(errors)
+
+        submitted_since = (
+            datetime.fromtimestamp(submitted_since_timestamp, timezone.utc)
+            if submitted_since_timestamp
+            else None
+        )
+
+        operations_to_return: List[Route53DomainsOperation] = []
+
+        for operation in self.__operations.values():
+            if len(operations_to_return) == max_items:
+                break
+
+            if statuses and operation.status not in statuses:
+                continue
+
+            if types and operation.type not in types:
+                continue
+
+            if submitted_since and operation.submitted_date < submitted_since:
+                continue
+
+            operations_to_return.append(operation)
+
+        if sort_by == "SubmittedDate":
+            operations_to_return.sort(
+                key=lambda op: op.submitted_date, reverse=sort_order == "ASC"
+            )
+
+        start_idx = 0 if marker is None else int(marker)
+        marker = (
+            None
+            if len(operations_to_return) < start_idx + max_items
+            else str(start_idx + max_items)
+        )
+        return operations_to_return[start_idx : start_idx + max_items], marker
+
+    @staticmethod
+    def __sort_by_submitted_date(operation: Route53DomainsOperation):
+        return operation.submitted_date
+
+
+route53domains_backends = BackendDict(
+    Route53DomainsBackend,
+    "route53domains",
+    use_boto3_regions=False,
+    additional_regions=["global"],
+)
diff --git a/moto/route53domains/responses.py b/moto/route53domains/responses.py
new file mode 100644
index 000000000000..06e8399093be
--- /dev/null
+++ b/moto/route53domains/responses.py
@@ -0,0 +1,77 @@
+import json
+from typing import Dict, List
+
+from moto.core.responses import BaseResponse
+from moto.route53domains.models import Route53DomainsBackend, route53domains_backends
+
+
+class Route53DomainsResponse(BaseResponse):
+    def __init__(self) -> None:
+        super().__init__(service_name="route53-domains")
+
+    @property
+    def route53domains_backend(self) -> Route53DomainsBackend:
+        """Return backend instance"""
+        return route53domains_backends[self.current_account]["global"]
+
+    def register_domain(self) -> str:
+        """Register a domain"""
+        domain_name: str = self._get_param("DomainName")
+        duration_in_years: int = self._get_int_param("DurationInYears")
+        auto_renew: bool = self._get_bool_param("AutoRenew", if_none=True)
+        admin_contact: Dict = self._get_param("AdminContact")
+        registrant_contact: Dict = self._get_param("RegistrantContact")
+        tech_contact: Dict = self._get_param("TechContact")
+        privacy_protection_admin_contact: bool = self._get_bool_param(
+            "PrivacyProtectAdminContact", if_none=True
+        )
+        privacy_protection_registrant_contact: bool = self._get_bool_param(
+            "PrivacyProtectRegistrantContact", if_none=True
+        )
+        privacy_protection_tech_contact: bool = self._get_bool_param(
+            "PrivacyProtectTechContact", if_none=True
+        )
+        extra_params: List[Dict] = self._get_param("ExtraParams", if_none=[])
+
+        operation = self.route53domains_backend.register_domain(
+            domain_name=domain_name,
+            duration_in_years=duration_in_years,
+            auto_renew=auto_renew,
+            admin_contact=admin_contact,
+            registrant_contact=registrant_contact,
+            tech_contact=tech_contact,
+            private_protect_admin_contact=privacy_protection_admin_contact,
+            private_protect_registrant_contact=privacy_protection_registrant_contact,
+            private_protect_tech_contact=privacy_protection_tech_contact,
+            extra_params=extra_params,
+        )
+
+        return json.dumps({"OperationId": operation.id})
+
+    def list_operations(self):
+        submitted_since_timestamp: int | None = self._get_int_param("SubmittedSince")
+        max_items: int = self._get_int_param("MaxItems")
+        statuses: List[str] | None = self._get_param("Status")
+        marker: str | None = self._get_param("Marker")
+        types: List[str] | None = self._get_param("Type")
+        sort_by: str | None = self._get_param("sort_by")
+        sort_order: str | None = self._get_param("SortOrder")
+
+        operations, marker = self.route53domains_backend.list_operations(
+            submitted_since_timestamp=submitted_since_timestamp,
+            max_items=max_items,
+            marker=marker,
+            statuses=statuses,
+            types=types,
+            sort_by=sort_by,
+            sort_order=sort_order,
+        )
+
+        res = {
+            "Operations": [operation.to_json() for operation in operations],
+        }
+
+        if marker:
+            res["NextPageMarker"] = marker
+
+        return json.dumps(res)
diff --git a/moto/route53domains/urls.py b/moto/route53domains/urls.py
new file mode 100644
index 000000000000..500444d22b8e
--- /dev/null
+++ b/moto/route53domains/urls.py
@@ -0,0 +1,11 @@
+"""route53domains base URL and path."""
+from .responses import Route53DomainsResponse
+
+url_bases = [
+    r"https?://route53domains\.(.+)\.amazonaws\.com",
+]
+
+
+url_paths = {
+    "{0}/$": Route53DomainsResponse.dispatch,
+}
diff --git a/moto/route53domains/validators.py b/moto/route53domains/validators.py
new file mode 100644
index 000000000000..122fa49b1647
--- /dev/null
+++ b/moto/route53domains/validators.py
@@ -0,0 +1,1044 @@
+import re
+from datetime import datetime, timedelta, timezone
+from ipaddress import ip_address
+from typing import Dict, List
+
+from moto.core.common_models import BaseModel
+from moto.moto_api._internal import MotoRandom
+from moto.route53domains.exceptions import UnsupportedTLDException
+
+DOMAIN_OPERATION_STATUSES = (
+    "SUBMITTED",
+    "IN_PROGRESS",
+    "ERROR",
+    "SUCCESSFUL",
+    "FAILED",
+)
+
+DOMAIN_OPERATION_TYPES = (
+    "REGISTER_DOMAIN",
+    "DELETE_DOMAIN",
+    "TRANSFER_IN_DOMAIN",
+    "UPDATE_DOMAIN_CONTACT",
+    "UPDATE_NAMESERVER",
+    "CHANGE_PRIVACY_PROTECTION",
+    "DOMAIN_LOCK",
+    "ENABLE_AUTORENEW",
+    "DISABLE_AUTORENEW",
+    "ADD_DNSSEC",
+    "REMOVE_DNSSEC",
+    "EXPIRE_DOMAIN",
+    "TRANSFER_OUT_DOMAIN",
+    "CHANGE_DOMAIN_OWNER",
+    "RENEW_DOMAIN",
+    "PUSH_DOMAIN",
+    "INTERNAL_TRANSFER_OUT_DOMAIN",
+    "INTERNAL_TRANSFER_IN_DOMAIN",
+)
+
+DOMAIN_OPERATION_STATUS_FLAGS = (
+    "PENDING_ACCEPTANCE",
+    "PENDING_CUSTOMER_ACTION",
+    "PENDING_AUTHORIZATION",
+    "PENDING_PAYMENT_VERIFICATION",
+    "PENDING_SUPPORT_CASE",
+)
+
+DOMAIN_CONTACT_DETAIL_CONTACT_TYPES = (
+    "PERSON",
+    "COMPANY",
+    "ASSOCIATION",
+    "PUBLIC_BODY",
+    "RESELLER",
+    "ORGANIZATION",
+)
+
+DOMAIN_CONTACT_DETAIL_COUNTRY_CODES = (
+    "AC",
+    "AD",
+    "AE",
+    "AF",
+    "AG",
+    "AI",
+    "AL",
+    "AM",
+    "AN",
+    "AO",
+    "AQ",
+    "AR",
+    "AS",
+    "AT",
+    "AU",
+    "AW",
+    "AX",
+    "AZ",
+    "BA",
+    "BB",
+    "BD",
+    "BE",
+    "BF",
+    "BG",
+    "BH",
+    "BI",
+    "BJ",
+    "BL",
+    "BM",
+    "BN",
+    "BO",
+    "BQ",
+    "BR",
+    "BS",
+    "BT",
+    "BV",
+    "BW",
+    "BY",
+    "BZ",
+    "CA",
+    "CC",
+    "CD",
+    "CF",
+    "CG",
+    "CH",
+    "CI",
+    "CK",
+    "CL",
+    "CM",
+    "CN",
+    "CO",
+    "CR",
+    "CU",
+    "CV",
+    "CW",
+    "CX",
+    "CY",
+    "CZ",
+    "DE",
+    "DJ",
+    "DK",
+    "DM",
+    "DO",
+    "DZ",
+    "EC",
+    "EE",
+    "EG",
+    "EH",
+    "ER",
+    "ES",
+    "ET",
+    "FI",
+    "FJ",
+    "FK",
+    "FM",
+    "FO",
+    "FR",
+    "GA",
+    "GB",
+    "GD",
+    "GE",
+    "GF",
+    "GG",
+    "GH",
+    "GI",
+    "GL",
+    "GM",
+    "GN",
+    "GP",
+    "GQ",
+    "GR",
+    "GS",
+    "GT",
+    "GU",
+    "GW",
+    "GY",
+    "HK",
+    "HM",
+    "HN",
+    "HR",
+    "HT",
+    "HU",
+    "ID",
+    "IE",
+    "IL",
+    "IM",
+    "IN",
+    "IO",
+    "IQ",
+    "IR",
+    "IS",
+    "IT",
+    "JE",
+    "JM",
+    "JO",
+    "JP",
+    "KE",
+    "KG",
+    "KH",
+    "KI",
+    "KM",
+    "KN",
+    "KP",
+    "KR",
+    "KW",
+    "KY",
+    "KZ",
+    "LA",
+    "LB",
+    "LC",
+    "LI",
+    "LK",
+    "LR",
+    "LS",
+    "LT",
+    "LU",
+    "LV",
+    "LY",
+    "MA",
+    "MC",
+    "MD",
+    "ME",
+    "MF",
+    "MG",
+    "MH",
+    "MK",
+    "ML",
+    "MM",
+    "MN",
+    "MO",
+    "MP",
+    "MQ",
+    "MR",
+    "MS",
+    "MT",
+    "MU",
+    "MV",
+    "MW",
+    "MX",
+    "MY",
+    "MZ",
+    "NA",
+    "NC",
+    "NE",
+    "NF",
+    "NG",
+    "NI",
+    "NL",
+    "NO",
+    "NP",
+    "NR",
+    "NU",
+    "NZ",
+    "OM",
+    "PA",
+    "PE",
+    "PF",
+    "PG",
+    "PH",
+    "PK",
+    "PL",
+    "PM",
+    "PN",
+    "PR",
+    "PS",
+    "PT",
+    "PW",
+    "PY",
+    "QA",
+    "RE",
+    "RO",
+    "RS",
+    "RU",
+    "RW",
+    "SA",
+    "SB",
+    "SC",
+    "SD",
+    "SE",
+    "SG",
+    "SH",
+    "SI",
+    "SJ",
+    "SK",
+    "SL",
+    "SM",
+    "SN",
+    "SO",
+    "SR",
+    "SS",
+    "ST",
+    "SV",
+    "SX",
+    "SY",
+    "SZ",
+    "TC",
+    "TD",
+    "TF",
+    "TG",
+    "TH",
+    "TJ",
+    "TK",
+    "TL",
+    "TM",
+    "TN",
+    "TO",
+    "TP",
+    "TR",
+    "TT",
+    "TV",
+    "TW",
+    "TZ",
+    "UA",
+    "UG",
+    "US",
+    "UY",
+    "UZ",
+    "VA",
+    "VC",
+    "VE",
+    "VG",
+    "VI",
+    "VN",
+    "VU",
+    "WF",
+    "WS",
+    "YE",
+    "YT",
+    "ZA",
+    "ZM",
+    "ZW",
+)
+
+# List of supported top-level domains that you can register with Amazon Route53
+AWS_SUPPORTED_TLDS = (
+    "ac",
+    "academy",
+    "accountants",
+    "actor",
+    "adult",
+    "agency",
+    "airforce",
+    "apartments",
+    "associates",
+    "auction",
+    "audio",
+    "band",
+    "bargains",
+    "bet",
+    "bike",
+    "bingo",
+    "biz",
+    "black",
+    "blue",
+    "boutique",
+    "builders",
+    "business",
+    "buzz",
+    "cab",
+    "cafe",
+    "camera",
+    "camp",
+    "capital",
+    "cards",
+    "care",
+    "careers",
+    "cash",
+    "casino",
+    "catering",
+    "cc",
+    "center",
+    "ceo",
+    "chat",
+    "cheap",
+    "church",
+    "city",
+    "claims",
+    "cleaning",
+    "click",
+    "clinic",
+    "clothing",
+    "cloud",
+    "club",
+    "coach",
+    "codes",
+    "coffee",
+    "college",
+    "com",
+    "community",
+    "company",
+    "computer",
+    "condos",
+    "construction",
+    "consulting",
+    "contractors",
+    "cool",
+    "coupons",
+    "credit",
+    "creditcard",
+    "cruises",
+    "dance",
+    "dating",
+    "deals",
+    "degree",
+    "delivery",
+    "democrat",
+    "dental",
+    "diamonds",
+    "diet",
+    "digital",
+    "direct",
+    "directory",
+    "discount",
+    "dog",
+    "domains",
+    "education",
+    "email",
+    "energy",
+    "engineering",
+    "enterprises",
+    "equipment",
+    "estate",
+    "events",
+    "exchange",
+    "expert",
+    "exposed",
+    "express",
+    "fail",
+    "farm",
+    "finance",
+    "financial",
+    "fish",
+    "fitness",
+    "flights",
+    "florist",
+    "flowers",
+    "fm",
+    "football",
+    "forsale",
+    "foundation",
+    "fund",
+    "furniture",
+    "futbol",
+    "fyi",
+    "gallery",
+    "games",
+    "gift",
+    "gifts",
+    "gives",
+    "glass",
+    "global",
+    "gmbh",
+    "gold",
+    "golf",
+    "graphics",
+    "gratis",
+    "green",
+    "gripe",
+    "group",
+    "guide",
+    "guitars",
+    "guru",
+    "haus",
+    "healthcare",
+    "help",
+    "hiv",
+    "hockey",
+    "holdings",
+    "holiday",
+    "host",
+    "hosting",
+    "house",
+    "im",
+    "immo",
+    "immobilien",
+    "industries",
+    "info",
+    "ink",
+    "institute",
+    "insure",
+    "international",
+    "investments",
+    "io",
+    "irish",
+    "jewelry",
+    "juegos",
+    "kaufen",
+    "kim",
+    "kitchen",
+    "kiwi",
+    "land",
+    "lease",
+    "legal",
+    "lgbt",
+    "life",
+    "lighting",
+    "limited",
+    "limo",
+    "link",
+    "live",
+    "loan",
+    "loans",
+    "lol",
+    "maison",
+    "management",
+    "marketing",
+    "mba",
+    "media",
+    "memorial",
+    "mobi",
+    "moda",
+    "money",
+    "mortgage",
+    "movie",
+    "name",
+    "net",
+    "network",
+    "news",
+    "ninja",
+    "onl",
+    "online",
+    "org",
+    "partners",
+    "parts",
+    "photo",
+    "photography",
+    "photos",
+    "pics",
+    "pictures",
+    "pink",
+    "pizza",
+    "place",
+    "plumbing",
+    "plus",
+    "poker",
+    "porn",
+    "press",
+    "pro",
+    "productions",
+    "properties",
+    "property",
+    "pub",
+    "qpon",
+    "recipes",
+    "red",
+    "reise",
+    "reisen",
+    "rentals",
+    "repair",
+    "report",
+    "republican",
+    "restaurant",
+    "reviews",
+    "rip",
+    "rocks",
+    "run",
+    "sale",
+    "sarl",
+    "school",
+    "schule",
+    "services",
+    "sex",
+    "sexy",
+    "shiksha",
+    "shoes",
+    "show",
+    "singles",
+    "site",
+    "soccer",
+    "social",
+    "solar",
+    "solutions",
+    "space",
+    "store",
+    "studio",
+    "style",
+    "sucks",
+    "supplies",
+    "supply",
+    "support",
+    "surgery",
+    "systems",
+    "tattoo",
+    "tax",
+    "taxi",
+    "team",
+    "tech",
+    "technology",
+    "tennis",
+    "theater",
+    "tienda",
+    "tips",
+    "tires",
+    "today",
+    "tools",
+    "tours",
+    "town",
+    "toys",
+    "trade",
+    "training",
+    "tv",
+    "university",
+    "uno",
+    "vacations",
+    "vegas",
+    "ventures",
+    "vg",
+    "viajes",
+    "video",
+    "villas",
+    "vision",
+    "voyage",
+    "watch",
+    "website",
+    "wedding",
+    "wiki",
+    "wine",
+    "works",
+    "world",
+    "wtf",
+    "xyz",
+    "zone",
+)
+
+VALID_DOMAIN_REGEX = re.compile(
+    r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]"
+)
+PHONE_NUMBER_REGEX = re.compile(r"\+\d*\.\d{10}$")
+
+
+class ValidationException(Exception):
+    def __init__(self, errors: List[str]):
+        super().__init__("\n\t".join(errors))
+        self.errors = errors
+
+
+class Route53DomainsOperation(BaseModel):
+    def __init__(
+        self,
+        id_: str,
+        domain_name: str,
+        status: str,
+        type_: str,
+        submitted_date: datetime,
+        last_updated_date: datetime,
+        message: str | None = None,
+        status_flag: str | None = None,
+    ):
+        self.id = id_
+        self.domain_name = domain_name
+        self.status = status
+        self.type = type_
+        self.submitted_date = submitted_date
+        self.last_updated_date = last_updated_date
+        self.message = message
+        self.status_flag = status_flag
+
+    @classmethod
+    def validate(
+        cls,
+        domain_name: str,
+        status: str,
+        type_: str,
+        message: str | None = None,
+        status_flag: str | None = None,
+    ):
+
+        id_ = str(MotoRandom().uuid4())
+        submitted_date = datetime.now(timezone.utc)
+        last_updated_date = datetime.now(timezone.utc)
+
+        return cls(
+            id_,
+            domain_name,
+            status,
+            type_,
+            submitted_date,
+            last_updated_date,
+            message,
+            status_flag,
+        )
+
+    def to_json(self) -> Dict:
+        d = {
+            "OperationId": self.id,
+            "DomainName": self.domain_name,
+            "LastUpdatedDate": self.last_updated_date.isoformat(),
+            "StatusFlag": self.status_flag,
+            "SubmittedDate": self.submitted_date.isoformat(),
+            "Type": self.type,
+        }
+
+        if self.message:
+            d["Message"] = self.message
+
+        return d
+
+
+class Route53DomainsContactDetail(BaseModel):
+    def __init__(
+        self,
+        address_line_1: str | None,
+        address_line_2: str | None,
+        city: str | None,
+        contact_type: str | None,
+        country_code: str | None,
+        email: str | None,
+        extra_params: List[Dict] | None,
+        fax: str | None,
+        first_name: str | None,
+        last_name: str | None,
+        organization_name: str | None,
+        phone_number: str | None,
+        state: str | None,
+        zip_code: str | None,
+    ):
+        super().__init__()
+        self.address_line_1 = address_line_1
+        self.address_line_2 = address_line_2
+        self.city = city
+        self.contact_type = contact_type
+        self.country_code = country_code
+        self.email = email
+        self.extra_params = extra_params
+        self.fax = fax
+        self.first_name = first_name
+        self.last_name = last_name
+        self.organization_name = organization_name
+        self.phone_number = phone_number
+        self.state = state
+        self.zip_code = zip_code
+
+    @classmethod
+    def validate(
+        cls,
+        address_line_1: str | None,
+        address_line_2: str | None,
+        city: str | None,
+        contact_type: str | None,
+        country_code: str | None,
+        email: str | None,
+        extra_params: List[Dict] | None,
+        fax: str | None,
+        first_name: str | None,
+        last_name: str | None,
+        organization_name: str | None,
+        phone_number: str | None,
+        state: str | None,
+        zip_code: str | None,
+    ):
+        errors = []
+
+        cls.__validate_str_len(address_line_1, "AddressLine1", 255, errors)
+        cls.__validate_str_len(address_line_2, "AddressLine2", 255, errors)
+        cls.__validate_str_len(city, "City", 255, errors)
+        cls.__validate_str_len(email, "Email", 255, errors)
+        cls.__validate_str_len(fax, "Fax", 255, errors)
+        cls.__validate_str_len(first_name, "FirstName", 255, errors)
+        cls.__validate_str_len(last_name, "LastName", 255, errors)
+        cls.__validate_str_len(state, "State", 255, errors)
+        cls.__validate_str_len(zip_code, "ZipCode", 255, errors)
+
+        if contact_type:
+            if contact_type not in DOMAIN_CONTACT_DETAIL_CONTACT_TYPES:
+                errors.append(f"Invalid contact type {contact_type}")
+            else:
+                if contact_type != "PERSON" and not organization_name:
+                    errors.append(
+                        "Must supply OrganizationName when ContactType is not PERSON"
+                    )
+
+        if country_code and country_code not in DOMAIN_CONTACT_DETAIL_COUNTRY_CODES:
+            errors.append(f"CountryCode {country_code} is invalid")
+
+        if phone_number and not PHONE_NUMBER_REGEX.match(phone_number):
+            errors.append("PhoneNumber is in an invalid format")
+
+        if errors:
+            raise ValidationException(errors)
+
+        return cls(
+            address_line_1,
+            address_line_2,
+            city,
+            contact_type,
+            country_code,
+            email,
+            extra_params,
+            fax,
+            first_name,
+            last_name,
+            organization_name,
+            phone_number,
+            state,
+            zip_code,
+        )
+
+    @classmethod
+    def validate_dict(cls, d: Dict):
+        address_line_1: str = d.get("AddressLine1")
+        address_line_2: str = d.get("AddressLine2")
+        city: str = d.get("City")
+        contact_type: str = d.get("ContactType")
+        country_code: str = d.get("CountryCode")
+        email: str = d.get("Email")
+        extra_params: List[Dict] = d.get("ExtraParams")
+        fax: str = d.get("Fax")
+        first_name: str = d.get("FirstName")
+        last_name: str = d.get("LastName")
+        organization_name: str = d.get("OrganizationName")
+        phone_number: str = d.get("PhoneNumber")
+        state: str = d.get("State")
+        zip_code: str = d.get("ZipCode")
+        return cls.validate(
+            address_line_1=address_line_1,
+            address_line_2=address_line_2,
+            city=city,
+            contact_type=contact_type,
+            country_code=country_code,
+            email=email,
+            extra_params=extra_params,
+            fax=fax,
+            first_name=first_name,
+            last_name=last_name,
+            organization_name=organization_name,
+            phone_number=phone_number,
+            state=state,
+            zip_code=zip_code,
+        )
+
+    @staticmethod
+    def __validate_str_len(
+        value: str, field_name: str, max_len: int, errors: List[str]
+    ):
+        if value and len(value) > max_len:
+            errors.append(f"Length of {field_name} is more than {max_len}")
+
+    def to_json(self) -> Dict:
+        d = {
+            "FirstName": self.first_name,
+            "LastName": self.last_name,
+            "ContactType": self.contact_type,
+            "OrganizationName": self.organization_name,
+            "AddressLine1": self.address_line_1,
+            "AddressLine2": self.address_line_2,
+            "City": self.city,
+            "State": self.state,
+            "CountryCode": self.country_code,
+            "ZipCode": self.zip_code,
+            "PhoneNumber": self.phone_number,
+            "Email": self.email,
+            "Fax": self.fax,
+            "ExtraParams": self.extra_params,
+        }
+
+        for key in d:
+            if d[key] is None:
+                del d[key]
+
+        return d
+
+
+class NameServer:
+    def __init__(self, name: str, glue_ips: List[str]):
+        self.name = name
+        self.glue_ips = glue_ips
+
+    @classmethod
+    def validate(cls, name: str, glue_ips: List[str] | None = None):
+        glue_ips = glue_ips or []
+        errors = []
+
+        if not name:
+            errors.append(f"{name} is not a valid DNS nameserver")
+
+        for ip in glue_ips:
+            try:
+                ip_address(ip)
+            except ValueError:
+                errors.append(f"{ip} is not a valid IP address")
+
+        if errors:
+            raise ValidationException(errors)
+
+        return cls(name, glue_ips)
+
+    @classmethod
+    def validate_dict(cls, d: Dict):
+        name = d.get("Name")
+        glue_ips = d.get("GlueIPs")
+        return cls.validate(name, glue_ips)
+
+    def to_json(self) -> Dict:
+        return {"Name": self.name, "GlueIps": self.glue_ips}
+
+
+class Route53Domain(BaseModel):
+    def __init__(
+        self,
+        domain_name: str,
+        name_servers: List[NameServer],
+        auto_renew: bool,
+        admin_contact: Route53DomainsContactDetail,
+        registrant_contact: Route53DomainsContactDetail,
+        tech_contact: Route53DomainsContactDetail,
+        admin_privacy: bool,
+        registrant_privacy: bool,
+        tech_privacy: bool,
+        registrar_name: str,
+        whois_server: str,
+        registrar_url: str,
+        abuse_contact_email: str,
+        abuse_contact_phone: str,
+        registry_domain_id: str,
+        creation_date: datetime,
+        updated_date: datetime,
+        expiration_date: datetime,
+        reseller: str,
+        status_list: List[str],
+        dns_sec_keys: List[Dict],
+        extra_params: List[Dict],
+    ):
+        self.domain_name = domain_name
+        self.name_servers = name_servers
+        self.auto_renew = auto_renew
+        self.admin_contact = admin_contact
+        self.registrant_contact = registrant_contact
+        self.tech_contact = tech_contact
+        self.admin_privacy = admin_privacy
+        self.registrant_privacy = registrant_privacy
+        self.tech_privacy = tech_privacy
+        self.registrar_name = registrar_name
+        self.whois_server = whois_server
+        self.registrar_url = registrar_url
+        self.abuse_contact_email = abuse_contact_email
+        self.abuse_contact_phone = abuse_contact_phone
+        self.registry_domain_id = registry_domain_id
+        self.creation_date = creation_date
+        self.updated_date = updated_date
+        self.expiration_date = expiration_date
+        self.reseller = reseller
+        self.status_list = status_list
+        self.dns_sec_keys = dns_sec_keys
+        self.extra_params = extra_params
+
+    @classmethod
+    def validate(
+        cls,
+        domain_name: str,
+        admin_contact: Route53DomainsContactDetail,
+        registrant_contact: Route53DomainsContactDetail,
+        tech_contact: Route53DomainsContactDetail,
+        name_servers: List[Dict] | None = None,
+        auto_renew: bool = True,
+        admin_privacy: bool = True,
+        registrant_privacy: bool = True,
+        tech_privacy: bool = True,
+        registrar_name: str | None = None,
+        whois_server: str | None = None,
+        registrar_url: str | None = None,
+        abuse_contact_email: str | None = None,
+        abuse_contact_phone: str | None = None,
+        registry_domain_id: str | None = None,
+        expiration_date: datetime | None = None,
+        reseller: str | None = None,
+        dns_sec_keys: List[Dict] | None = None,
+        extra_params: List[Dict] | None = None,
+    ):
+        input_errors = []
+
+        cls.__validate_domain_name(domain_name, input_errors)
+
+        name_servers = name_servers or []
+        try:
+            name_servers = [
+                NameServer.validate_dict(name_server) for name_server in name_servers
+            ] or [
+                NameServer.validate(name="ns-2048.awscdn-64.net"),
+                NameServer.validate(name="ns-2051.awscdn-67.net"),
+                NameServer.validate(name="ns-2050.awscdn-66.net"),
+                NameServer.validate(name="ns-2049.awscdn-65.net"),
+            ]
+        except ValidationException as e:
+            input_errors += e.errors
+
+        creation_date = datetime.now(timezone.utc)
+        updated_date = datetime.now(timezone.utc)
+        expiration_date = expiration_date or datetime.now(timezone.utc) + timedelta(
+            days=365 * 10
+        )
+        registrar_name = registrar_name or "GANDI SAS"
+        whois_server = whois_server or "whois.gandi.net"
+        registrar_url = registrar_url or "http://www.gandi.net"
+        abuse_contact_email = abuse_contact_email or "abuse@support.gandi.net"
+        status_list = ["SUCCEEDED"]
+
+        time_until_expiration = expiration_date - datetime.now(timezone.utc)
+        if time_until_expiration < timedelta(
+            days=365
+        ) or time_until_expiration > timedelta(days=365 * 10):
+            input_errors.append(
+                "ExpirationDate must by between 1 and 10 years from now"
+            )
+
+        if input_errors:
+            raise ValidationException(input_errors)
+
+        return cls(
+            domain_name=domain_name,
+            name_servers=name_servers,
+            auto_renew=auto_renew,
+            admin_contact=admin_contact,
+            registrant_contact=registrant_contact,
+            tech_contact=tech_contact,
+            admin_privacy=admin_privacy,
+            registrant_privacy=registrant_privacy,
+            tech_privacy=tech_privacy,
+            registrar_name=registrar_name,
+            whois_server=whois_server,
+            registrar_url=registrar_url,
+            abuse_contact_email=abuse_contact_email,
+            abuse_contact_phone=abuse_contact_phone,
+            registry_domain_id=registry_domain_id,
+            creation_date=creation_date,
+            updated_date=updated_date,
+            expiration_date=expiration_date,
+            reseller=reseller,
+            status_list=status_list,
+            dns_sec_keys=dns_sec_keys,
+            extra_params=extra_params,
+        )
+
+    @staticmethod
+    def __validate_domain_name(domain_name: str, input_errors: List[str]):
+        if not VALID_DOMAIN_REGEX.match(domain_name):
+            input_errors.append("Invalid domain name")
+            return
+
+        tld = domain_name.split(".")[-1]
+        if tld not in AWS_SUPPORTED_TLDS:
+            raise UnsupportedTLDException(tld)
+
+    def to_json(self):
+        return {
+            "DomainName": self.domain_name,
+            "NameServers": [name_server.to_json() for name_server in self.name_servers],
+            "AutoRenew": self.auto_renew,
+            "AdminContact": self.admin_contact.to_json(),
+            "RegistrantContact": self.registrant_contact.to_json(),
+            "TechContact": self.tech_contact.to_json(),
+            "AdminPrivacy": self.admin_privacy,
+            "RegistrantPrivacy": self.registrant_privacy,
+            "TechPrivacy": self.tech_privacy,
+            "RegistrarName": self.registrar_name,
+            "WhoIsServer": self.whois_server,
+            "RegistrarUrl": self.registrar_url,
+            "AbuseContactEmail": self.abuse_contact_email,
+            "AbuseContactPhone": self.abuse_contact_phone,
+            "RegistryDomainId": "",
+            "CreationDate": self.creation_date.isoformat(),
+            "UpdateDate": self.updated_date.isoformat(),
+            "ExpirationDate": self.expiration_date.isoformat(),
+            "Reseller": self.reseller,
+            "DnsSec": "",
+            "StatusList": self.status_list,
+            "DnsSecKeys": self.dns_sec_keys,
+            "BillingContact": self.admin_contact.to_json(),
+        }
diff --git a/tests/test_route53domains/__init__.py b/tests/test_route53domains/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/tests/test_route53domains/test_route53domains_domain.py b/tests/test_route53domains/test_route53domains_domain.py
new file mode 100644
index 000000000000..51ce003e7313
--- /dev/null
+++ b/tests/test_route53domains/test_route53domains_domain.py
@@ -0,0 +1,214 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict
+
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from moto import mock_aws
+
+
+@pytest.fixture(scope="function")
+def register_domain_parameters() -> Dict:
+    return {
+        "DomainName": "domain.com",
+        "DurationInYears": 3,
+        "AutoRenew": True,
+        "AdminContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "RegistrantContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "TechContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "PrivacyProtectAdminContact": True,
+        "PrivacyProtectRegistrantContact": True,
+        "PrivacyProtectTechContact": True,
+    }
+
+
+@pytest.fixture(scope="function")
+def invalid_register_domain_parameters(register_domain_parameters: Dict) -> Dict:
+    register_domain_parameters["DomainName"] = "a"
+    register_domain_parameters["DurationInYears"] = 500
+    return register_domain_parameters
+
+
+@mock_aws
+def test_route53domains_register_domain(register_domain_parameters: Dict):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    res = route53domains_client.register_domain(**register_domain_parameters)
+
+    operation_id = res["OperationId"]
+
+    operations = route53domains_client.list_operations()["Operations"]
+    for operation in operations:
+        if operation["OperationId"] == operation_id:
+            return
+
+    assert (
+        False
+    ), "Could not find expected operation id returned from `register_domain` in operation list"
+
+
+@mock_aws
+def test_route53domains_register_domain_creates_hosted_zone(
+    register_domain_parameters: Dict,
+):
+    """Test good register domain API calls."""
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53_client = boto3.client("route53", region_name="global")
+    route53domains_client.register_domain(**register_domain_parameters)
+
+    res = route53_client.list_hosted_zones()
+    for zone in res["HostedZones"]:
+        if zone["Name"] == "domain.com":
+            return
+
+    assert (
+        False
+    ), "`register_domain` did not create a new hosted zone with the same name"
+
+
+@mock_aws
+def test_route53domains_register_domain_fails_on_invalid_input(
+    invalid_register_domain_parameters: Dict,
+):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53_client = boto3.client("route53", region_name="global")
+    with pytest.raises(ClientError) as exc:
+        route53domains_client.register_domain(**invalid_register_domain_parameters)
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidInput"
+
+    res = route53_client.list_hosted_zones()
+    assert len(res["HostedZones"]) == 0
+
+
+@mock_aws
+def test_route53domains_register_domain_fails_on_invalid_tld(
+    register_domain_parameters: Dict,
+):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53_client = boto3.client("route53", region_name="global")
+
+    register_domain_parameters["DomainName"] = "test.non-existing-tld"
+    with pytest.raises(ClientError) as exc:
+        route53domains_client.register_domain(**register_domain_parameters)
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "UnsupportedTLD"
+
+    res = route53_client.list_hosted_zones()
+    assert len(res["HostedZones"]) == 0
+
+
+@mock_aws
+def test_route53domains_list_operations(register_domain_parameters: Dict):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53domains_client.register_domain(**register_domain_parameters)
+
+    operations = route53domains_client.list_operations()["Operations"]
+    assert len(operations) == 1
+
+    future_time = datetime.now(timezone.utc) + timedelta(minutes=1)
+    operations = route53domains_client.list_operations(
+        SubmittedSince=future_time.timestamp()
+    )["Operations"]
+    assert len(operations) == 0
+
+    operations = route53domains_client.list_operations(Status=["SUCCESSFUL"])[
+        "Operations"
+    ]
+    assert len(operations) == 1
+
+    operations = route53domains_client.list_operations(Status=["IN_PROGRESS"])[
+        "Operations"
+    ]
+    assert len(operations) == 0
+
+    operations = route53domains_client.list_operations(Type=["REGISTER_DOMAIN"])[
+        "Operations"
+    ]
+    assert len(operations) == 1
+
+    operations = route53domains_client.list_operations(Type=["DELETE_DOMAIN"])[
+        "Operations"
+    ]
+    assert len(operations) == 0
+
+
+@mock_aws
+def test_list_operations_invalid_input():
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    with pytest.raises(ClientError) as exc:
+        _ = route53domains_client.list_operations(Type=["INVALID_TYPE"])["Operations"]
+
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidInput"
+
+    with pytest.raises(ClientError) as exc:
+        _ = route53domains_client.list_operations(Status=["INVALID_STATUS"])[
+            "Operations"
+        ]
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidInput"
+
+
+@mock_aws
+def test_list_operations_marker(register_domain_parameters):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    domain_params = register_domain_parameters.copy()
+    route53domains_client.register_domain(**domain_params)
+    domain_params["DomainName"] = "seconddomain.com"
+    route53domains_client.register_domain(**domain_params)
+    domain_params["DomainName"] = "thirddomain.com"
+    route53domains_client.register_domain(**domain_params)
+    operations_res = route53domains_client.list_operations()
+    assert len(operations_res["Operations"]) == 3
+    operations_res = route53domains_client.list_operations(Marker="0", MaxItems=1)
+    assert len(operations_res["Operations"]) == 1
+    operations_res = route53domains_client.list_operations(
+        Marker=operations_res["NextPageMarker"]
+    )
+    assert len(operations_res["Operations"]) == 2
+
+
+@mock_aws
+def test_duplicate_requests(register_domain_parameters):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53domains_client.register_domain(**register_domain_parameters)
+    with pytest.raises(ClientError) as exc:
+        route53domains_client.register_domain(**register_domain_parameters)
+    err = exc.value.response["Error"]
+    assert err["Code"] == "DuplicateRequest"


Code review comments:
diff_hunk: @@ -0,0 +1,1044 @@
+import re
+from datetime import datetime, timedelta, timezone
+from ipaddress import ip_address
+from typing import Dict, List
+
+from moto.core.common_models import BaseModel
+from moto.moto_api._internal import MotoRandom
path: moto/route53domains/validators.py
text: @user1:
```suggestion
from moto.moto_api._internal import mock_random as random
```
That allows you to use the regular methods further down (`random.uuid4()`), makes it a bit easier to read.

diff_hunk: @@ -0,0 +1,161 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict, List, Tuple
+
+from moto.core.base_backend import BackendDict, BaseBackend
+from moto.route53 import route53_backends
+from moto.route53.models import Route53Backend
+
+from .exceptions import DuplicateRequestException, InvalidInputException
+from .validators import (
+    DOMAIN_OPERATION_STATUSES,
+    DOMAIN_OPERATION_TYPES,
+    Route53Domain,
+    Route53DomainsContactDetail,
+    Route53DomainsOperation,
+    ValidationException,
+)
+
+
+class Route53DomainsBackend(BaseBackend):
+    """Implementation of Route53Domains APIs."""
+
+    def __init__(self, region_name: str, account_id: str):
+        super().__init__(region_name, account_id)
+        self.__route53_backend: Route53Backend = route53_backends[account_id]["global"]
+        self.__domains: Dict[str, Route53Domain] = {}
+        self.__operations: Dict[str, Route53DomainsOperation] = {}
+
+    def register_domain(
+        self,
+        domain_name: str,
+        duration_in_years: int,
+        auto_renew: bool,
+        admin_contact: Dict,
+        registrant_contact: Dict,
+        tech_contact: Dict,
+        private_protect_admin_contact: bool,
+        private_protect_registrant_contact: bool,
+        private_protect_tech_contact: bool,
+        extra_params: List[Dict],
+    ) -> Route53DomainsOperation:
+        """Register a domain"""
+        requested_operation = Route53DomainsOperation.validate(
+            domain_name=domain_name, status="SUCCESSFUL", type_="REGISTER_DOMAIN"
+        )
+
+        self.__validate_duplicate_requests(requested_operation)
+
+        expiration_date = datetime.now(timezone.utc) + timedelta(
+            days=365 * duration_in_years
+        )
+
+        try:
+
+            domain = Route53Domain.validate(
+                domain_name=domain_name,
+                auto_renew=auto_renew,
+                admin_contact=Route53DomainsContactDetail.validate_dict(admin_contact),
+                registrant_contact=Route53DomainsContactDetail.validate_dict(
+                    registrant_contact
+                ),
+                tech_contact=Route53DomainsContactDetail.validate_dict(tech_contact),
+                admin_privacy=private_protect_admin_contact,
+                registrant_privacy=private_protect_registrant_contact,
+                tech_privacy=private_protect_tech_contact,
+                expiration_date=expiration_date,
+                extra_params=extra_params,
+            )
+
+        except ValidationException as e:
+            raise InvalidInputException(e.errors)
+
+        self.__operations[requested_operation.id] = requested_operation
+
+        self.__route53_backend.create_hosted_zone(
+            name=domain.domain_name, private_zone=False
+        )
+
+        self.__domains[domain_name] = domain
+        return requested_operation
+
+    def __validate_duplicate_requests(
+        self, requested_operation: Route53DomainsOperation
+    ):
+        for operation in self.__operations.values():
+            if (
+                operation.domain_name == requested_operation.domain_name
+                and operation.type == requested_operation.type
+            ):
+                raise DuplicateRequestException()
+
+    def list_operations(
+        self,
+        submitted_since_timestamp: int | None = None,
path: moto/route53domains/models.py
text: @user1:
Moto still supports Python 3.8, which doesn't support the |-operand. So they should all be converted:
`Optional[int] = None`

@author:
Fair enough, although I really like the new `|` operator

diff_hunk: @@ -0,0 +1,214 @@
+from datetime import datetime, timedelta, timezone
+from typing import Dict
+
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from moto import mock_aws
+
+
+@pytest.fixture(scope="function")
+def register_domain_parameters() -> Dict:
+    return {
+        "DomainName": "domain.com",
+        "DurationInYears": 3,
+        "AutoRenew": True,
+        "AdminContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "RegistrantContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "TechContact": {
+            "FirstName": "First",
+            "LastName": "Last",
+            "ContactType": "PERSON",
+            "AddressLine1": "address 1",
+            "AddressLine2": "address 2",
+            "City": "New York City",
+            "CountryCode": "US",
+            "ZipCode": "123123123",
+            "Email": "email@gmail.com",
+            "Fax": "+1.1234567890",
+        },
+        "PrivacyProtectAdminContact": True,
+        "PrivacyProtectRegistrantContact": True,
+        "PrivacyProtectTechContact": True,
+    }
+
+
+@pytest.fixture(scope="function")
+def invalid_register_domain_parameters(register_domain_parameters: Dict) -> Dict:
+    register_domain_parameters["DomainName"] = "a"
+    register_domain_parameters["DurationInYears"] = 500
+    return register_domain_parameters
+
+
+@mock_aws
+def test_route53domains_register_domain(register_domain_parameters: Dict):
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    res = route53domains_client.register_domain(**register_domain_parameters)
+
+    operation_id = res["OperationId"]
+
+    operations = route53domains_client.list_operations()["Operations"]
+    for operation in operations:
+        if operation["OperationId"] == operation_id:
+            return
+
+    assert (
+        False
+    ), "Could not find expected operation id returned from `register_domain` in operation list"
+
+
+@mock_aws
+def test_route53domains_register_domain_creates_hosted_zone(
+    register_domain_parameters: Dict,
+):
+    """Test good register domain API calls."""
+    route53domains_client = boto3.client("route53domains", region_name="global")
+    route53_client = boto3.client("route53", region_name="global")
+    route53domains_client.register_domain(**register_domain_parameters)
+
+    res = route53_client.list_hosted_zones()
+    for zone in res["HostedZones"]:
+        if zone["Name"] == "domain.com":
+            return
path: tests/test_route53domains/test_route53domains_domain.py
text: @user1:
Pytest doesn't like having `return`-statements inside a test, so this should probably be rewritten to something like:
```
assert "domain.com" in [zone["Name"] for zone in res["HostedZones"]]
```

diff_hunk: @@ -0,0 +1,1044 @@
+import re
+from datetime import datetime, timedelta, timezone
+from ipaddress import ip_address
+from typing import Dict, List
+
+from moto.core.common_models import BaseModel
+from moto.moto_api._internal import MotoRandom
+from moto.route53domains.exceptions import UnsupportedTLDException
+
+DOMAIN_OPERATION_STATUSES = (
+    "SUBMITTED",
+    "IN_PROGRESS",
+    "ERROR",
+    "SUCCESSFUL",
+    "FAILED",
+)
+
+DOMAIN_OPERATION_TYPES = (
+    "REGISTER_DOMAIN",
+    "DELETE_DOMAIN",
+    "TRANSFER_IN_DOMAIN",
+    "UPDATE_DOMAIN_CONTACT",
+    "UPDATE_NAMESERVER",
+    "CHANGE_PRIVACY_PROTECTION",
+    "DOMAIN_LOCK",
+    "ENABLE_AUTORENEW",
+    "DISABLE_AUTORENEW",
+    "ADD_DNSSEC",
+    "REMOVE_DNSSEC",
+    "EXPIRE_DOMAIN",
+    "TRANSFER_OUT_DOMAIN",
+    "CHANGE_DOMAIN_OWNER",
+    "RENEW_DOMAIN",
+    "PUSH_DOMAIN",
+    "INTERNAL_TRANSFER_OUT_DOMAIN",
+    "INTERNAL_TRANSFER_IN_DOMAIN",
+)
+
+DOMAIN_OPERATION_STATUS_FLAGS = (
+    "PENDING_ACCEPTANCE",
+    "PENDING_CUSTOMER_ACTION",
+    "PENDING_AUTHORIZATION",
+    "PENDING_PAYMENT_VERIFICATION",
+    "PENDING_SUPPORT_CASE",
+)
+
+DOMAIN_CONTACT_DETAIL_CONTACT_TYPES = (
+    "PERSON",
+    "COMPANY",
+    "ASSOCIATION",
+    "PUBLIC_BODY",
+    "RESELLER",
+    "ORGANIZATION",
+)
+
+DOMAIN_CONTACT_DETAIL_COUNTRY_CODES = (
+    "AC",
+    "AD",
+    "AE",
+    "AF",
+    "AG",
+    "AI",
+    "AL",
+    "AM",
+    "AN",
+    "AO",
+    "AQ",
+    "AR",
+    "AS",
+    "AT",
+    "AU",
+    "AW",
+    "AX",
+    "AZ",
+    "BA",
+    "BB",
+    "BD",
+    "BE",
+    "BF",
+    "BG",
+    "BH",
+    "BI",
+    "BJ",
+    "BL",
+    "BM",
+    "BN",
+    "BO",
+    "BQ",
+    "BR",
+    "BS",
+    "BT",
+    "BV",
+    "BW",
+    "BY",
+    "BZ",
+    "CA",
+    "CC",
+    "CD",
+    "CF",
+    "CG",
+    "CH",
+    "CI",
+    "CK",
+    "CL",
+    "CM",
+    "CN",
+    "CO",
+    "CR",
+    "CU",
+    "CV",
+    "CW",
+    "CX",
+    "CY",
+    "CZ",
+    "DE",
+    "DJ",
+    "DK",
+    "DM",
+    "DO",
+    "DZ",
+    "EC",
+    "EE",
+    "EG",
+    "EH",
+    "ER",
+    "ES",
+    "ET",
+    "FI",
+    "FJ",
+    "FK",
+    "FM",
+    "FO",
+    "FR",
+    "GA",
+    "GB",
+    "GD",
+    "GE",
+    "GF",
+    "GG",
+    "GH",
+    "GI",
+    "GL",
+    "GM",
+    "GN",
+    "GP",
+    "GQ",
+    "GR",
+    "GS",
+    "GT",
+    "GU",
+    "GW",
+    "GY",
+    "HK",
+    "HM",
+    "HN",
+    "HR",
+    "HT",
+    "HU",
+    "ID",
+    "IE",
+    "IL",
+    "IM",
+    "IN",
+    "IO",
+    "IQ",
+    "IR",
+    "IS",
+    "IT",
+    "JE",
+    "JM",
+    "JO",
+    "JP",
+    "KE",
+    "KG",
+    "KH",
+    "KI",
+    "KM",
+    "KN",
+    "KP",
+    "KR",
+    "KW",
+    "KY",
+    "KZ",
+    "LA",
+    "LB",
+    "LC",
+    "LI",
+    "LK",
+    "LR",
+    "LS",
+    "LT",
+    "LU",
+    "LV",
+    "LY",
+    "MA",
+    "MC",
+    "MD",
+    "ME",
+    "MF",
+    "MG",
+    "MH",
+    "MK",
+    "ML",
+    "MM",
+    "MN",
+    "MO",
+    "MP",
+    "MQ",
+    "MR",
+    "MS",
+    "MT",
+    "MU",
+    "MV",
+    "MW",
+    "MX",
+    "MY",
+    "MZ",
+    "NA",
+    "NC",
+    "NE",
+    "NF",
+    "NG",
+    "NI",
+    "NL",
+    "NO",
+    "NP",
+    "NR",
+    "NU",
+    "NZ",
+    "OM",
+    "PA",
+    "PE",
+    "PF",
+    "PG",
+    "PH",
+    "PK",
+    "PL",
+    "PM",
+    "PN",
+    "PR",
+    "PS",
+    "PT",
+    "PW",
+    "PY",
+    "QA",
+    "RE",
+    "RO",
+    "RS",
+    "RU",
+    "RW",
+    "SA",
+    "SB",
+    "SC",
+    "SD",
+    "SE",
+    "SG",
+    "SH",
+    "SI",
+    "SJ",
+    "SK",
+    "SL",
+    "SM",
+    "SN",
+    "SO",
+    "SR",
+    "SS",
+    "ST",
+    "SV",
+    "SX",
+    "SY",
+    "SZ",
+    "TC",
+    "TD",
+    "TF",
+    "TG",
+    "TH",
+    "TJ",
+    "TK",
+    "TL",
+    "TM",
+    "TN",
+    "TO",
+    "TP",
+    "TR",
+    "TT",
+    "TV",
+    "TW",
+    "TZ",
+    "UA",
+    "UG",
+    "US",
+    "UY",
+    "UZ",
+    "VA",
+    "VC",
+    "VE",
+    "VG",
+    "VI",
+    "VN",
+    "VU",
+    "WF",
+    "WS",
+    "YE",
+    "YT",
+    "ZA",
+    "ZM",
+    "ZW",
+)
+
+# List of supported top-level domains that you can register with Amazon Route53
+AWS_SUPPORTED_TLDS = (
+    "ac",
+    "academy",
+    "accountants",
+    "actor",
+    "adult",
+    "agency",
+    "airforce",
+    "apartments",
+    "associates",
+    "auction",
+    "audio",
+    "band",
+    "bargains",
+    "bet",
+    "bike",
+    "bingo",
+    "biz",
+    "black",
+    "blue",
+    "boutique",
+    "builders",
+    "business",
+    "buzz",
+    "cab",
+    "cafe",
+    "camera",
+    "camp",
+    "capital",
+    "cards",
+    "care",
+    "careers",
+    "cash",
+    "casino",
+    "catering",
+    "cc",
+    "center",
+    "ceo",
+    "chat",
+    "cheap",
+    "church",
+    "city",
+    "claims",
+    "cleaning",
+    "click",
+    "clinic",
+    "clothing",
+    "cloud",
+    "club",
+    "coach",
+    "codes",
+    "coffee",
+    "college",
+    "com",
+    "community",
+    "company",
+    "computer",
+    "condos",
+    "construction",
+    "consulting",
+    "contractors",
+    "cool",
+    "coupons",
+    "credit",
+    "creditcard",
+    "cruises",
+    "dance",
+    "dating",
+    "deals",
+    "degree",
+    "delivery",
+    "democrat",
+    "dental",
+    "diamonds",
+    "diet",
+    "digital",
+    "direct",
+    "directory",
+    "discount",
+    "dog",
+    "domains",
+    "education",
+    "email",
+    "energy",
+    "engineering",
+    "enterprises",
+    "equipment",
+    "estate",
+    "events",
+    "exchange",
+    "expert",
+    "exposed",
+    "express",
+    "fail",
+    "farm",
+    "finance",
+    "financial",
+    "fish",
+    "fitness",
+    "flights",
+    "florist",
+    "flowers",
+    "fm",
+    "football",
+    "forsale",
+    "foundation",
+    "fund",
+    "furniture",
+    "futbol",
+    "fyi",
+    "gallery",
+    "games",
+    "gift",
+    "gifts",
+    "gives",
+    "glass",
+    "global",
+    "gmbh",
+    "gold",
+    "golf",
+    "graphics",
+    "gratis",
+    "green",
+    "gripe",
+    "group",
+    "guide",
+    "guitars",
+    "guru",
+    "haus",
+    "healthcare",
+    "help",
+    "hiv",
+    "hockey",
+    "holdings",
+    "holiday",
+    "host",
+    "hosting",
+    "house",
+    "im",
+    "immo",
+    "immobilien",
+    "industries",
+    "info",
+    "ink",
+    "institute",
+    "insure",
+    "international",
+    "investments",
+    "io",
+    "irish",
+    "jewelry",
+    "juegos",
+    "kaufen",
+    "kim",
+    "kitchen",
+    "kiwi",
+    "land",
+    "lease",
+    "legal",
+    "lgbt",
+    "life",
+    "lighting",
+    "limited",
+    "limo",
+    "link",
+    "live",
+    "loan",
+    "loans",
+    "lol",
+    "maison",
+    "management",
+    "marketing",
+    "mba",
+    "media",
+    "memorial",
+    "mobi",
+    "moda",
+    "money",
+    "mortgage",
+    "movie",
+    "name",
+    "net",
+    "network",
+    "news",
+    "ninja",
+    "onl",
+    "online",
+    "org",
+    "partners",
+    "parts",
+    "photo",
+    "photography",
+    "photos",
+    "pics",
+    "pictures",
+    "pink",
+    "pizza",
+    "place",
+    "plumbing",
+    "plus",
+    "poker",
+    "porn",
+    "press",
+    "pro",
+    "productions",
+    "properties",
+    "property",
+    "pub",
+    "qpon",
+    "recipes",
+    "red",
+    "reise",
+    "reisen",
+    "rentals",
+    "repair",
+    "report",
+    "republican",
+    "restaurant",
+    "reviews",
+    "rip",
+    "rocks",
+    "run",
+    "sale",
+    "sarl",
+    "school",
+    "schule",
+    "services",
+    "sex",
+    "sexy",
+    "shiksha",
+    "shoes",
+    "show",
+    "singles",
+    "site",
+    "soccer",
+    "social",
+    "solar",
+    "solutions",
+    "space",
+    "store",
+    "studio",
+    "style",
+    "sucks",
+    "supplies",
+    "supply",
+    "support",
+    "surgery",
+    "systems",
+    "tattoo",
+    "tax",
+    "taxi",
+    "team",
+    "tech",
+    "technology",
+    "tennis",
+    "theater",
+    "tienda",
+    "tips",
+    "tires",
+    "today",
+    "tools",
+    "tours",
+    "town",
+    "toys",
+    "trade",
+    "training",
+    "tv",
+    "university",
+    "uno",
+    "vacations",
+    "vegas",
+    "ventures",
+    "vg",
+    "viajes",
+    "video",
+    "villas",
+    "vision",
+    "voyage",
+    "watch",
+    "website",
+    "wedding",
+    "wiki",
+    "wine",
+    "works",
+    "world",
+    "wtf",
+    "xyz",
+    "zone",
+)
+
+VALID_DOMAIN_REGEX = re.compile(
+    r"(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]"
+)
+PHONE_NUMBER_REGEX = re.compile(r"\+\d*\.\d{10}$")
path: moto/route53domains/validators.py
text: @user1:
```suggestion
PHONE_NUMBER_REGEX = re.compile(r"\+\d+\.\d+$")
```
Minor niggle - but phone numbers are notoriously hard to validate, that's why it stood out to  me.

The exact length of a phone number differs by country, so I think we should be very liberal in what we accept