Repository: getmoto/moto
Pull request: Route53 query-logging-config APIs
Addition of the Route53 APIs:

- `create_query_logging_config`
- `get_query_logging_config`
- `list_query_logging_config`
- `delete_query_logging_config`

Note:  `create_query_logging_config()` does not create a log stream for an edge location.  That's supposed to happen the first time that the edge location responds to DNS queries for the specified hosted zone.

Problem statement:
paginate decorator requiring parameters that are not required
Note:  What I'm trying to do is internal to moto and not a typical scenario, so it's a low priority issue.

I have the following code that I was planning to use in a new Route53 API implementation:

```
        from moto.logs import logs_backends  # pylint: disable=import-outside-toplevel
        response = logs_backends[region].describe_log_groups()
```

This code snippet results in a pagination_wrapper() error:

```
        ....
        paginator = Paginator(
            max_results=limit,
            max_results_default=pagination_config.get("limit_default"),
            starting_token=input_token,
            page_ending_range_keys=pagination_config.get("page_ending_range_keys"),
            param_values_to_check=kwargs,
            fail_on_invalid_token=fail_on_invalid_token,
        )
>       results = func(*args, **kwargs)
E       TypeError: describe_log_groups() missing 1 required positional argument: 'log_group_name_prefix'
```

`log_group_name_prefix` is not a required argument.  The same call to `describe_log_groups()` invoked as an API call works fine and does not require the `log_group_name_prefix` argument.

I think the API call `describe_log_groups()` should set a default value of `None` to `log_group_name_prefix`.  If this is acceptable, I can make this change with the code changes I'm making now.

Hints from issue discussion:
Makes sense @kbalk 
#4437

Commit message: Add logic, test for InvalidPaginationToken

Patch to review:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 76b5cdabc042..7f48252aa1a6 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -3506,7 +3506,7 @@
 - [X] create_health_check
 - [X] create_hosted_zone
 - [ ] create_key_signing_key
-- [ ] create_query_logging_config
+- [X] create_query_logging_config
 - [ ] create_reusable_delegation_set
 - [ ] create_traffic_policy
 - [ ] create_traffic_policy_instance
@@ -3516,7 +3516,7 @@
 - [X] delete_health_check
 - [X] delete_hosted_zone
 - [ ] delete_key_signing_key
-- [ ] delete_query_logging_config
+- [X] delete_query_logging_config
 - [ ] delete_reusable_delegation_set
 - [ ] delete_traffic_policy
 - [ ] delete_traffic_policy_instance
@@ -3536,7 +3536,7 @@
 - [X] get_hosted_zone
 - [ ] get_hosted_zone_count
 - [ ] get_hosted_zone_limit
-- [ ] get_query_logging_config
+- [X] get_query_logging_config
 - [ ] get_reusable_delegation_set
 - [ ] get_reusable_delegation_set_limit
 - [ ] get_traffic_policy
@@ -3547,7 +3547,7 @@
 - [X] list_hosted_zones
 - [X] list_hosted_zones_by_name
 - [ ] list_hosted_zones_by_vpc
-- [ ] list_query_logging_configs
+- [X] list_query_logging_configs
 - [ ] list_resource_record_sets
 - [ ] list_reusable_delegation_sets
 - [X] list_tags_for_resource
@@ -4642,4 +4642,4 @@
 - workmailmessageflow
 - workspaces
 - xray
-</details>
\ No newline at end of file
+</details>
diff --git a/moto/logs/models.py b/moto/logs/models.py
index ac8549fbf27d..ee426b1496f8 100644
--- a/moto/logs/models.py
+++ b/moto/logs/models.py
@@ -652,7 +652,9 @@ def delete_log_group(self, log_group_name):
         del self.groups[log_group_name]
 
     @paginate(pagination_model=PAGINATION_MODEL)
-    def describe_log_groups(self, log_group_name_prefix, limit=None, next_token=None):
+    def describe_log_groups(
+        self, log_group_name_prefix=None, limit=None, next_token=None
+    ):
         if log_group_name_prefix is None:
             log_group_name_prefix = ""
 
diff --git a/moto/route53/exceptions.py b/moto/route53/exceptions.py
new file mode 100644
index 000000000000..94981dad6f22
--- /dev/null
+++ b/moto/route53/exceptions.py
@@ -0,0 +1,73 @@
+"""Exceptions raised by the Route53 service."""
+from moto.core.exceptions import RESTError
+
+
+class Route53ClientError(RESTError):
+    """Base class for Route53 errors."""
+
+    def __init__(self, *args, **kwargs):
+        kwargs.setdefault("template", "single_error")
+        super().__init__(*args, **kwargs)
+
+
+class InvalidInput(Route53ClientError):
+    """Malformed ARN for the CloudWatch log group."""
+
+    code = 400
+
+    def __init__(self):
+        message = "The ARN for the CloudWatch Logs log group is invalid"
+        super().__init__("InvalidInput", message)
+
+
+class InvalidPaginationToken(Route53ClientError):
+    """Bad NextToken specified when listing query logging configs."""
+
+    code = 400
+
+    def __init__(self):
+        message = (
+            "Route 53 can't get the next page of query logging configurations "
+            "because the specified value for NextToken is invalid."
+        )
+        super().__init__("InvalidPaginationToken", message)
+
+
+class NoSuchCloudWatchLogsLogGroup(Route53ClientError):
+    """CloudWatch LogGroup has a permissions policy, but does not exist."""
+
+    code = 404
+
+    def __init__(self):
+        message = "The specified CloudWatch Logs log group doesn't exist."
+        super().__init__("NoSuchCloudWatchLogsLogGroup", message)
+
+
+class NoSuchHostedZone(Route53ClientError):
+    """HostedZone does not exist."""
+
+    code = 404
+
+    def __init__(self, host_zone_id):
+        message = f"No hosted zone found with ID: {host_zone_id}"
+        super().__init__("NoSuchHostedZone", message)
+
+
+class NoSuchQueryLoggingConfig(Route53ClientError):
+    """Query log config does not exist."""
+
+    code = 404
+
+    def __init__(self):
+        message = "The query logging configuration does not exist"
+        super().__init__("NoSuchQueryLoggingConfig", message)
+
+
+class QueryLoggingConfigAlreadyExists(Route53ClientError):
+    """Query log config exists for the hosted zone."""
+
+    code = 409
+
+    def __init__(self):
+        message = "A query logging configuration already exists for this hosted zone"
+        super().__init__("QueryLoggingConfigAlreadyExists", message)
diff --git a/moto/route53/models.py b/moto/route53/models.py
index 09a458e96a10..4c6bd85c9d60 100644
--- a/moto/route53/models.py
+++ b/moto/route53/models.py
@@ -1,13 +1,23 @@
+"""Route53Backend class with methods for supported APIs."""
 import itertools
 from collections import defaultdict
+import re
 
 import string
 import random
 import uuid
 from jinja2 import Template
 
-from moto.core import BaseBackend, CloudFormationModel
-
+from moto.route53.exceptions import (
+    InvalidInput,
+    NoSuchCloudWatchLogsLogGroup,
+    NoSuchHostedZone,
+    NoSuchQueryLoggingConfig,
+    QueryLoggingConfigAlreadyExists,
+)
+from moto.core import BaseBackend, BaseModel, CloudFormationModel
+from moto.utilities.paginator import paginate
+from .utils import PAGINATION_MODEL
 
 ROUTE53_ID_CHOICE = string.ascii_uppercase + string.digits
 
@@ -342,7 +352,7 @@ def __init__(self, hosted_zone_id, record_sets):
 
     @property
     def physical_resource_id(self):
-        return "arn:aws:route53:::hostedzone/{0}".format(self.hosted_zone_id)
+        return f"arn:aws:route53:::hostedzone/{self.hosted_zone_id}"
 
     @staticmethod
     def cloudformation_name_type():
@@ -372,11 +382,37 @@ def create_from_cloudformation_json(
         return record_set_group
 
 
+class QueryLoggingConfig(BaseModel):
+
+    """QueryLoggingConfig class; this object isn't part of Cloudformation."""
+
+    def __init__(
+        self, query_logging_config_id, hosted_zone_id, cloudwatch_logs_log_group_arn
+    ):
+        self.hosted_zone_id = hosted_zone_id
+        self.cloudwatch_logs_log_group_arn = cloudwatch_logs_log_group_arn
+        self.query_logging_config_id = query_logging_config_id
+        self.location = f"https://route53.amazonaws.com/2013-04-01/queryloggingconfig/{self.query_logging_config_id}"
+
+    def to_xml(self):
+        template = Template(
+            """<QueryLoggingConfig>
+                <CloudWatchLogsLogGroupArn>{{ query_logging_config.cloudwatch_logs_log_group_arn }}</CloudWatchLogsLogGroupArn>
+                <HostedZoneId>{{ query_logging_config.hosted_zone_id }}</HostedZoneId>
+                <Id>{{ query_logging_config.query_logging_config_id }}</Id>
+            </QueryLoggingConfig>"""
+        )
+        # The "Location" value must be put into the header; that's done in
+        # responses.py.
+        return template.render(query_logging_config=self)
+
+
 class Route53Backend(BaseBackend):
     def __init__(self):
         self.zones = {}
         self.health_checks = {}
         self.resource_tags = defaultdict(dict)
+        self.query_logging_configs = {}
 
     def create_hosted_zone(self, name, private_zone, comment=None):
         new_id = create_route53_zone_id()
@@ -414,13 +450,10 @@ def change_resource_record_sets(self, the_zone, change_list):
             cleaned_hosted_zone_name = the_zone.name.strip(".")
 
             if not cleaned_record_name.endswith(cleaned_hosted_zone_name):
-                error_msg = """
+                error_msg = f"""
                 An error occurred (InvalidChangeBatch) when calling the ChangeResourceRecordSets operation:
-                RRSet with DNS name %s is not permitted in zone %s
-                """ % (
-                    record_set["Name"],
-                    the_zone.name,
-                )
+                RRSet with DNS name {record_set["Name"]} is not permitted in zone {the_zone.name}
+                """
                 return error_msg
 
             if not record_set["Name"].endswith("."):
@@ -477,6 +510,7 @@ def get_hosted_zone_by_name(self, name):
         for zone in self.list_hosted_zones():
             if zone.name == name:
                 return zone
+        return None
 
     def delete_hosted_zone(self, id_):
         return self.zones.pop(id_.replace("/hostedzone/", ""), None)
@@ -493,5 +527,92 @@ def list_health_checks(self):
     def delete_health_check(self, health_check_id):
         return self.health_checks.pop(health_check_id, None)
 
+    @staticmethod
+    def _validate_arn(region, arn):
+        match = re.match(fr"arn:aws:logs:{region}:\d{{12}}:log-group:.+", arn)
+        if not arn or not match:
+            raise InvalidInput()
+
+        # The CloudWatch Logs log group must be in the "us-east-1" region.
+        match = re.match(r"^(?:[^:]+:){3}(?P<region>[^:]+).*", arn)
+        if match.group("region") != "us-east-1":
+            raise InvalidInput()
+
+    def create_query_logging_config(self, region, hosted_zone_id, log_group_arn):
+        """Process the create_query_logging_config request."""
+        # Does the hosted_zone_id exist?
+        response = self.list_hosted_zones()
+        zones = list(response) if response else []
+        for zone in zones:
+            if zone.id == hosted_zone_id:
+                break
+        else:
+            raise NoSuchHostedZone(hosted_zone_id)
+
+        # Ensure CloudWatch Logs log ARN is valid, otherwise raise an error.
+        self._validate_arn(region, log_group_arn)
+
+        # Note:  boto3 checks the resource policy permissions before checking
+        # whether the log group exists.  moto doesn't have a way of checking
+        # the resource policy, so in some instances moto will complain
+        # about a log group that doesn't exist whereas boto3 will complain
+        # that "The resource policy that you're using for Route 53 query
+        # logging doesn't grant Route 53 sufficient permission to create
+        # a log stream in the specified log group."
+
+        from moto.logs import logs_backends  # pylint: disable=import-outside-toplevel
+
+        response = logs_backends[region].describe_log_groups()
+        log_groups = response[0] if response else []
+        for entry in log_groups:
+            if log_group_arn == entry["arn"]:
+                break
+        else:
+            # There is no CloudWatch Logs log group with the specified ARN.
+            raise NoSuchCloudWatchLogsLogGroup()
+
+        # Verify there is no existing query log config using the same hosted
+        # zone.
+        for query_log in self.query_logging_configs.values():
+            if query_log.hosted_zone_id == hosted_zone_id:
+                raise QueryLoggingConfigAlreadyExists()
+
+        # Create an instance of the query logging config.
+        query_logging_config_id = str(uuid.uuid4())
+        query_logging_config = QueryLoggingConfig(
+            query_logging_config_id, hosted_zone_id, log_group_arn
+        )
+        self.query_logging_configs[query_logging_config_id] = query_logging_config
+        return query_logging_config
+
+    def delete_query_logging_config(self, query_logging_config_id):
+        """Delete query logging config, if it exists."""
+        if query_logging_config_id not in self.query_logging_configs:
+            raise NoSuchQueryLoggingConfig()
+        self.query_logging_configs.pop(query_logging_config_id)
+
+    def get_query_logging_config(self, query_logging_config_id):
+        """Return query logging config, if it exists."""
+        if query_logging_config_id not in self.query_logging_configs:
+            raise NoSuchQueryLoggingConfig()
+        return self.query_logging_configs[query_logging_config_id]
+
+    @paginate(pagination_model=PAGINATION_MODEL)
+    def list_query_logging_configs(
+        self, hosted_zone_id=None, next_token=None, max_results=None,
+    ):  # pylint: disable=unused-argument
+        """Return a list of query logging configs."""
+        if hosted_zone_id:
+            # Does the hosted_zone_id exist?
+            response = self.list_hosted_zones()
+            zones = list(response) if response else []
+            for zone in zones:
+                if zone.id == hosted_zone_id:
+                    break
+            else:
+                raise NoSuchHostedZone(hosted_zone_id)
+
+        return list(self.query_logging_configs.values())
+
 
 route53_backend = Route53Backend()
diff --git a/moto/route53/responses.py b/moto/route53/responses.py
index e0563e4a0c67..ff6e67891386 100644
--- a/moto/route53/responses.py
+++ b/moto/route53/responses.py
@@ -1,14 +1,20 @@
-from jinja2 import Template
+"""Handles Route53 API requests, invokes method and returns response."""
 from urllib.parse import parse_qs, urlparse
 
-from moto.core.responses import BaseResponse
-from .models import route53_backend
+from jinja2 import Template
 import xmltodict
 
+from moto.core.responses import BaseResponse
+from moto.core.exceptions import InvalidToken
+from moto.route53.exceptions import Route53ClientError, InvalidPaginationToken
+from moto.route53.models import route53_backend
+
 XMLNS = "https://route53.amazonaws.com/doc/2013-04-01/"
 
 
 class Route53(BaseResponse):
+    """Handler for Route53 requests and responses."""
+
     def list_or_create_hostzone_response(self, request, full_url, headers):
         self.setup_class(request, full_url, headers)
 
@@ -56,7 +62,7 @@ def list_hosted_zones_by_name_response(self, request, full_url, headers):
         dnsname, zones = route53_backend.list_hosted_zones_by_name(dnsname)
 
         template = Template(LIST_HOSTED_ZONES_BY_NAME_RESPONSE)
-        return 200, headers, template.render(zones=zones, dnsname=dnsname)
+        return 200, headers, template.render(zones=zones, dnsname=dnsname, xmlns=XMLNS)
 
     def get_or_delete_hostzone_response(self, request, full_url, headers):
         self.setup_class(request, full_url, headers)
@@ -148,15 +154,20 @@ def health_check_response(self, request, full_url, headers):
                 caller_reference, health_check_args
             )
             template = Template(CREATE_HEALTH_CHECK_RESPONSE)
-            return 201, headers, template.render(health_check=health_check)
+            return 201, headers, template.render(health_check=health_check, xmlns=XMLNS)
         elif method == "DELETE":
             health_check_id = parsed_url.path.split("/")[-1]
             route53_backend.delete_health_check(health_check_id)
-            return 200, headers, DELETE_HEALTH_CHECK_RESPONSE
+            template = Template(DELETE_HEALTH_CHECK_RESPONSE)
+            return 200, headers, template.render(xmlns=XMLNS)
         elif method == "GET":
             template = Template(LIST_HEALTH_CHECKS_RESPONSE)
             health_checks = route53_backend.list_health_checks()
-            return 200, headers, template.render(health_checks=health_checks)
+            return (
+                200,
+                headers,
+                template.render(health_checks=health_checks, xmlns=XMLNS),
+            )
 
     def not_implemented_response(self, request, full_url, headers):
         self.setup_class(request, full_url, headers)
@@ -167,7 +178,7 @@ def not_implemented_response(self, request, full_url, headers):
         elif "trafficpolicyinstances" in full_url:
             action = "policies"
         raise NotImplementedError(
-            "The action for {0} has not been implemented for route 53".format(action)
+            f"The action for {action} has not been implemented for route 53"
         )
 
     def list_or_change_tags_for_resource_request(self, request, full_url, headers):
@@ -196,7 +207,6 @@ def list_or_change_tags_for_resource_request(self, request, full_url, headers):
 
             route53_backend.change_tags_for_resource(id_, tags)
             template = Template(CHANGE_TAGS_FOR_RESOURCE_RESPONSE)
-
             return 200, headers, template.render()
 
     def get_change(self, request, full_url, headers):
@@ -206,20 +216,109 @@ def get_change(self, request, full_url, headers):
             parsed_url = urlparse(full_url)
             change_id = parsed_url.path.rstrip("/").rsplit("/", 1)[1]
             template = Template(GET_CHANGE_RESPONSE)
-            return 200, headers, template.render(change_id=change_id)
+            return 200, headers, template.render(change_id=change_id, xmlns=XMLNS)
+
+    def list_or_create_query_logging_config_response(self, request, full_url, headers):
+        self.setup_class(request, full_url, headers)
+
+        if request.method == "POST":
+            json_body = xmltodict.parse(self.body)["CreateQueryLoggingConfigRequest"]
+            hosted_zone_id = json_body["HostedZoneId"]
+            log_group_arn = json_body["CloudWatchLogsLogGroupArn"]
+            try:
+                query_logging_config = route53_backend.create_query_logging_config(
+                    self.region, hosted_zone_id, log_group_arn
+                )
+            except Route53ClientError as r53error:
+                return r53error.code, {}, r53error.description
+
+            template = Template(CREATE_QUERY_LOGGING_CONFIG_RESPONSE)
+            headers["Location"] = query_logging_config.location
+            return (
+                201,
+                headers,
+                template.render(query_logging_config=query_logging_config, xmlns=XMLNS),
+            )
+
+        elif request.method == "GET":
+            parsed_url = urlparse(full_url)
+            query_params = parse_qs(parsed_url.query)
+
+            # For some reason the params are returned as a list.
+            param_list = query_params.get("hostedzoneid")
+            hosted_zone_id = param_list[0] if param_list else None
 
+            param_list = query_params.get("nexttoken")
+            next_token = param_list[0] if param_list else None
 
-def no_such_hosted_zone_error(zoneid, headers={}):
+            param_list = query_params.get("maxresults")
+            max_results = int(param_list[0]) if param_list else None
+
+            try:
+                try:
+                    # The paginator picks up named arguments.
+                    (
+                        all_configs,
+                        next_token,
+                    ) = route53_backend.list_query_logging_configs(
+                        hosted_zone_id=hosted_zone_id,
+                        next_token=next_token,
+                        max_results=max_results,
+                    )
+                except InvalidToken as exc:
+                    raise InvalidPaginationToken() from exc
+            except Route53ClientError as r53error:
+                return r53error.code, {}, r53error.description
+
+            template = Template(LIST_QUERY_LOGGING_CONFIGS_RESPONSE)
+            return (
+                200,
+                headers,
+                template.render(
+                    query_logging_configs=all_configs,
+                    next_token=next_token,
+                    xmlns=XMLNS,
+                ),
+            )
+
+    def get_or_delete_query_logging_config_response(self, request, full_url, headers):
+        self.setup_class(request, full_url, headers)
+        parsed_url = urlparse(full_url)
+        query_logging_config_id = parsed_url.path.rstrip("/").rsplit("/", 1)[1]
+
+        if request.method == "GET":
+            try:
+                query_logging_config = route53_backend.get_query_logging_config(
+                    query_logging_config_id
+                )
+            except Route53ClientError as r53error:
+                return r53error.code, {}, r53error.description
+            template = Template(GET_QUERY_LOGGING_CONFIG_RESPONSE)
+            return (
+                200,
+                headers,
+                template.render(query_logging_config=query_logging_config, xmlns=XMLNS),
+            )
+
+        elif request.method == "DELETE":
+            try:
+                route53_backend.delete_query_logging_config(query_logging_config_id)
+            except Route53ClientError as r53error:
+                return r53error.code, {}, r53error.description
+            return 200, headers, ""
+
+
+def no_such_hosted_zone_error(zoneid, headers=None):
+    if not headers:
+        headers = {}
     headers["X-Amzn-ErrorType"] = "NoSuchHostedZone"
     headers["Content-Type"] = "text/xml"
-    message = "Zone %s Not Found" % zoneid
-    error_response = (
-        "<Error><Code>NoSuchHostedZone</Code><Message>%s</Message></Error>" % message
-    )
-    error_response = '<ErrorResponse xmlns="%s">%s</ErrorResponse>' % (
-        XMLNS,
-        error_response,
-    )
+    error_response = f"""<ErrorResponse xmlns="{XMLNS}">
+        <Error>
+            <Code>NoSuchHostedZone</Code>
+            <Message>Zone {zoneid} Not Found</Message>
+        </Error>
+    </ErrorResponse>"""
     return 404, headers, error_response
 
 
@@ -323,7 +422,7 @@ def no_such_hosted_zone_error(zoneid, headers={}):
    <IsTruncated>false</IsTruncated>
 </ListHostedZonesResponse>"""
 
-LIST_HOSTED_ZONES_BY_NAME_RESPONSE = """<ListHostedZonesByNameResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
+LIST_HOSTED_ZONES_BY_NAME_RESPONSE = """<ListHostedZonesByNameResponse xmlns="{{ xmlns }}">
   {% if dnsname %}
   <DNSName>{{ dnsname }}</DNSName>
   {% endif %}
@@ -346,12 +445,12 @@ def no_such_hosted_zone_error(zoneid, headers={}):
 </ListHostedZonesByNameResponse>"""
 
 CREATE_HEALTH_CHECK_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
-<CreateHealthCheckResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
+<CreateHealthCheckResponse xmlns="{{ xmlns }}">
   {{ health_check.to_xml() }}
 </CreateHealthCheckResponse>"""
 
 LIST_HEALTH_CHECKS_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
-<ListHealthChecksResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
+<ListHealthChecksResponse xmlns="{{ xmlns }}">
    <HealthChecks>
    {% for health_check in health_checks %}
       {{ health_check.to_xml() }}
@@ -362,14 +461,36 @@ def no_such_hosted_zone_error(zoneid, headers={}):
 </ListHealthChecksResponse>"""
 
 DELETE_HEALTH_CHECK_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
-    <DeleteHealthCheckResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
+    <DeleteHealthCheckResponse xmlns="{{ xmlns }}">
 </DeleteHealthCheckResponse>"""
 
 GET_CHANGE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
-<GetChangeResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
+<GetChangeResponse xmlns="{{ xmlns }}">
    <ChangeInfo>
       <Status>INSYNC</Status>
       <SubmittedAt>2010-09-10T01:36:41.958Z</SubmittedAt>
       <Id>{{ change_id }}</Id>
    </ChangeInfo>
 </GetChangeResponse>"""
+
+CREATE_QUERY_LOGGING_CONFIG_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
+<CreateQueryLoggingConfigResponse xmlns="{{ xmlns }}">
+  {{ query_logging_config.to_xml() }}
+</CreateQueryLoggingConfigResponse>"""
+
+GET_QUERY_LOGGING_CONFIG_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
+<CreateQueryLoggingConfigResponse xmlns="{{ xmlns }}">
+  {{ query_logging_config.to_xml() }}
+</CreateQueryLoggingConfigResponse>"""
+
+LIST_QUERY_LOGGING_CONFIGS_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
+<ListQueryLoggingConfigsResponse xmlns="{{ xmlns }}">
+   <QueryLoggingConfigs>
+      {% for query_logging_config in query_logging_configs %}
+         {{ query_logging_config.to_xml() }}
+      {% endfor %}
+   </QueryLoggingConfigs>
+   {% if next_token %}
+      <NextToken>{{ next_token }}</NextToken>
+   {% endif %}
+</ListQueryLoggingConfigsResponse>"""
diff --git a/moto/route53/urls.py b/moto/route53/urls.py
index 6574d051f5aa..1b498207cbf8 100644
--- a/moto/route53/urls.py
+++ b/moto/route53/urls.py
@@ -1,3 +1,4 @@
+"""Route53 base URL and path."""
 from .responses import Route53
 
 url_bases = [r"https?://route53(.*)\.amazonaws.com"]
@@ -22,4 +23,6 @@ def tag_response2(*args, **kwargs):
     r"{0}/(?P<api_version>[\d_-]+)/tags/hostedzone/(?P<zone_id>[^/]+)$": tag_response2,
     r"{0}/(?P<api_version>[\d_-]+)/trafficpolicyinstances/*": Route53().not_implemented_response,
     r"{0}/(?P<api_version>[\d_-]+)/change/(?P<change_id>[^/]+)$": Route53().get_change,
+    r"{0}/(?P<api_version>[\d_-]+)/queryloggingconfig$": Route53().list_or_create_query_logging_config_response,
+    r"{0}/(?P<api_version>[\d_-]+)/queryloggingconfig/(?P<query_id>[^/]+)$": Route53().get_or_delete_query_logging_config_response,
 }
diff --git a/moto/route53/utils.py b/moto/route53/utils.py
new file mode 100644
index 000000000000..858f488787a2
--- /dev/null
+++ b/moto/route53/utils.py
@@ -0,0 +1,10 @@
+"""Pagination control model for Route53."""
+
+PAGINATION_MODEL = {
+    "list_query_logging_configs": {
+        "input_token": "next_token",
+        "limit_key": "max_results",
+        "limit_default": 100,
+        "page_ending_range_keys": ["hosted_zone_id"],
+    },
+}
diff --git a/tests/test_route53/test_route53_query_logging_config.py b/tests/test_route53/test_route53_query_logging_config.py
new file mode 100644
index 000000000000..de374c13e236
--- /dev/null
+++ b/tests/test_route53/test_route53_query_logging_config.py
@@ -0,0 +1,277 @@
+"""Route53 unit tests specific to query_logging_config APIs."""
+import pytest
+
+import boto3
+from botocore.exceptions import ClientError
+
+from moto import mock_logs
+from moto import mock_route53
+from moto.core import ACCOUNT_ID
+from moto.core.utils import get_random_hex
+
+# The log group must be in the us-east-1 region.
+TEST_REGION = "us-east-1"
+
+
+def create_hosted_zone_id(route53_client, hosted_zone_test_name):
+    """Return ID of a newly created Route53 public hosted zone"""
+    response = route53_client.create_hosted_zone(
+        Name=hosted_zone_test_name,
+        CallerReference=f"test_caller_ref_{get_random_hex(6)}",
+    )
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 201
+    assert "HostedZone" in response and response["HostedZone"]["Id"]
+    return response["HostedZone"]["Id"]
+
+
+def create_log_group_arn(logs_client, hosted_zone_test_name):
+    """Return ARN of a newly created CloudWatch log group."""
+    log_group_name = f"/aws/route53/{hosted_zone_test_name}"
+    response = logs_client.create_log_group(logGroupName=log_group_name)
+    assert response["ResponseMetadata"]["HTTPStatusCode"] == 200
+
+    log_group_arn = None
+    response = logs_client.describe_log_groups()
+    for entry in response["logGroups"]:
+        if entry["logGroupName"] == log_group_name:
+            log_group_arn = entry["arn"]
+            break
+    return log_group_arn
+
+
+@mock_logs
+@mock_route53
+def test_create_query_logging_config_bad_args():
+    """Test bad arguments to create_query_logging_config()."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+    hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+    log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+    # Check exception:  NoSuchHostedZone
+    with pytest.raises(ClientError) as exc:
+        client.create_query_logging_config(
+            HostedZoneId="foo", CloudWatchLogsLogGroupArn=log_group_arn,
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NoSuchHostedZone"
+    assert "No hosted zone found with ID: foo" in err["Message"]
+
+    # Check exception:  InvalidInput (bad CloudWatch Logs log ARN)
+    with pytest.raises(ClientError) as exc:
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id,
+            CloudWatchLogsLogGroupArn=f"arn:aws:logs:{TEST_REGION}:{ACCOUNT_ID}:foo-bar:foo",
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidInput"
+    assert "The ARN for the CloudWatch Logs log group is invalid" in err["Message"]
+
+    # Check exception:  InvalidInput (CloudWatch Logs log not in us-east-1)
+    with pytest.raises(ClientError) as exc:
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id,
+            CloudWatchLogsLogGroupArn=log_group_arn.replace(TEST_REGION, "us-west-1"),
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidInput"
+    assert "The ARN for the CloudWatch Logs log group is invalid" in err["Message"]
+
+    # Check exception:  NoSuchCloudWatchLogsLogGroup
+    with pytest.raises(ClientError) as exc:
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id,
+            CloudWatchLogsLogGroupArn=log_group_arn.replace(
+                hosted_zone_test_name, "foo"
+            ),
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NoSuchCloudWatchLogsLogGroup"
+    assert "The specified CloudWatch Logs log group doesn't exist" in err["Message"]
+
+    # Check exception:  QueryLoggingConfigAlreadyExists
+    client.create_query_logging_config(
+        HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+    )
+    with pytest.raises(ClientError) as exc:
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+        )
+    err = exc.value.response["Error"]
+    assert err["Code"] == "QueryLoggingConfigAlreadyExists"
+    assert (
+        "A query logging configuration already exists for this hosted zone"
+        in err["Message"]
+    )
+
+
+@mock_logs
+@mock_route53
+def test_create_query_logging_config_good_args():
+    """Test a valid create_logging_config() request."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+    hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+    log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+    response = client.create_query_logging_config(
+        HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+    )
+    config = response["QueryLoggingConfig"]
+    assert config["HostedZoneId"] == hosted_zone_id.split("/")[-1]
+    assert config["CloudWatchLogsLogGroupArn"] == log_group_arn
+    assert config["Id"]
+
+    location = response["Location"]
+    assert (
+        location
+        == f"https://route53.amazonaws.com/2013-04-01/queryloggingconfig/{config['Id']}"
+    )
+
+
+@mock_logs
+@mock_route53
+def test_delete_query_logging_config():
+    """Test valid and invalid delete_query_logging_config requests."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    # Create a query logging config that can then be deleted.
+    hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+    hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+    log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+    query_response = client.create_query_logging_config(
+        HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+    )
+
+    # Test the deletion.
+    query_id = query_response["QueryLoggingConfig"]["Id"]
+    response = client.delete_query_logging_config(Id=query_id)
+    # There is no response other than the usual ResponseMetadata.
+    assert list(response.keys()) == ["ResponseMetadata"]
+
+    # Test the deletion of a non-existent query logging config, i.e., the
+    # one that was just deleted.
+    with pytest.raises(ClientError) as exc:
+        client.delete_query_logging_config(Id=query_id)
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NoSuchQueryLoggingConfig"
+    assert "The query logging configuration does not exist" in err["Message"]
+
+
+@mock_logs
+@mock_route53
+def test_get_query_logging_config():
+    """Test valid and invalid get_query_logging_config requests."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    # Create a query logging config that can then be retrieved.
+    hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+    hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+    log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+
+    query_response = client.create_query_logging_config(
+        HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+    )
+
+    # Test the retrieval.
+    query_id = query_response["QueryLoggingConfig"]["Id"]
+    response = client.get_query_logging_config(Id=query_id)
+    config = response["QueryLoggingConfig"]
+    assert config["HostedZoneId"] == hosted_zone_id.split("/")[-1]
+    assert config["CloudWatchLogsLogGroupArn"] == log_group_arn
+    assert config["Id"]
+
+    # Test the retrieval of a non-existent query logging config.
+    with pytest.raises(ClientError) as exc:
+        client.get_query_logging_config(Id="1234567890")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NoSuchQueryLoggingConfig"
+    assert "The query logging configuration does not exist" in err["Message"]
+
+
+@mock_logs
+@mock_route53
+def test_list_query_logging_configs_bad_args():
+    """Test bad arguments to list_query_logging_configs()."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    # Check exception:  NoSuchHostedZone
+    with pytest.raises(ClientError) as exc:
+        client.list_query_logging_configs(HostedZoneId="foo", MaxResults="10")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "NoSuchHostedZone"
+    assert "No hosted zone found with ID: foo" in err["Message"]
+
+    # Create a couple of query logging configs to work with.
+    for _ in range(3):
+        hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+        hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+        log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+        )
+
+    # Retrieve a query logging config, then request more with an invalid token.
+    client.list_query_logging_configs(MaxResults="1")
+    with pytest.raises(ClientError) as exc:
+        client.list_query_logging_configs(NextToken="foo")
+    err = exc.value.response["Error"]
+    assert err["Code"] == "InvalidPaginationToken"
+    assert (
+        "Route 53 can't get the next page of query logging configurations "
+        "because the specified value for NextToken is invalid." in err["Message"]
+    )
+
+
+@mock_logs
+@mock_route53
+def test_list_query_logging_configs_good_args():
+    """Test valid arguments to list_query_logging_configs()."""
+    client = boto3.client("route53", region_name=TEST_REGION)
+    logs_client = boto3.client("logs", region_name=TEST_REGION)
+
+    # Test when there are no query logging configs.
+    response = client.list_query_logging_configs()
+    query_logging_configs = response["QueryLoggingConfigs"]
+    assert len(query_logging_configs) == 0
+
+    # Create a couple of query logging configs to work with.
+    zone_ids = []
+    for _ in range(10):
+        hosted_zone_test_name = f"route53_query_log_{get_random_hex(6)}.test"
+        hosted_zone_id = create_hosted_zone_id(client, hosted_zone_test_name)
+        zone_ids.append(hosted_zone_id)
+
+        log_group_arn = create_log_group_arn(logs_client, hosted_zone_test_name)
+        client.create_query_logging_config(
+            HostedZoneId=hosted_zone_id, CloudWatchLogsLogGroupArn=log_group_arn,
+        )
+
+    # Verify all 10 of the query logging configs can be retrieved in one go.
+    response = client.list_query_logging_configs()
+    query_logging_configs = response["QueryLoggingConfigs"]
+    assert len(query_logging_configs) == 10
+    for idx, query_logging_config in enumerate(query_logging_configs):
+        assert query_logging_config["HostedZoneId"] == zone_ids[idx].split("/")[-1]
+
+    # Request only two of the query logging configs and verify there's a
+    # next_token.
+    response = client.list_query_logging_configs(MaxResults="2")
+    assert len(response["QueryLoggingConfigs"]) == 2
+    assert response["NextToken"]
+
+    # Request the remaining 8 query logging configs and verify there is
+    # no next token.
+    response = client.list_query_logging_configs(
+        MaxResults="8", NextToken=response["NextToken"]
+    )
+    assert len(response["QueryLoggingConfigs"]) == 8
+    assert "NextToken" not in response


Code review comments:
diff_hunk: @@ -206,20 +216,109 @@ def get_change(self, request, full_url, headers):
             parsed_url = urlparse(full_url)
             change_id = parsed_url.path.rstrip("/").rsplit("/", 1)[1]
             template = Template(GET_CHANGE_RESPONSE)
-            return 200, headers, template.render(change_id=change_id)
+            return 200, headers, template.render(change_id=change_id, xmlns=XMLNS)
+
+    def list_or_create_query_logging_config_response(self, request, full_url, headers):
+        self.setup_class(request, full_url, headers)
+
+        if request.method == "POST":
+            json_body = xmltodict.parse(self.body)["CreateQueryLoggingConfigRequest"]
+            hosted_zone_id = json_body["HostedZoneId"]
+            log_group_arn = json_body["CloudWatchLogsLogGroupArn"]
+            try:
+                query_logging_config = route53_backend.create_query_logging_config(
+                    self.region, hosted_zone_id, log_group_arn
+                )
+            except Route53ClientError as r53error:
+                return r53error.code, {}, r53error.description
+
+            template = Template(CREATE_QUERY_LOGGING_CONFIG_RESPONSE)
+            headers["Location"] = query_logging_config.location
+            return (
+                201,
+                headers,
+                template.render(query_logging_config=query_logging_config, xmlns=XMLNS),
+            )
+
+        elif request.method == "GET":
+            parsed_url = urlparse(full_url)
+            query_params = parse_qs(parsed_url.query)
+
+            # For some reason the params are returned as a list.
path: moto/route53/responses.py
text: @user1:
All params can all be retrieved with one call: `self._get_param("hostedzoneid")`.
Don't know why it's returned as a list, but at least the utility takes care of the ugly details

@author:
Yup, that's much nicer.  Thank you!