Repository: getmoto/moto
Pull request #6341 (2023-05-24 20:21:41)

add CreateTime property to EC2 key pairs
currently the EC2 Key Pair model doesn't expose the `CreateTime` property (this property [is returned](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/ec2/client/describe_key_pairs.html) by `boto3`, however)

I've implemented this by following the same pattern established with the `CreateDate` property on an IAM User in `moto`


Patch:
diff --git a/.github/workflows/payment_notification.yml b/.github/workflows/payment_notification.yml
new file mode 100644
index 000000000..d41127117
--- /dev/null
+++ b/.github/workflows/payment_notification.yml
@@ -0,0 +1,30 @@
+# When the appropriate label is applied, notify user that payment is waiting
+#
+name: "Payment Notification"
+
+on:
+  pull_request:
+    types: [ labeled ]
+
+jobs:
+  test:
+    if: ${{ github.repository == 'getmoto/moto' && github.event.label.name == 'payment-approved' }}
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Leave comment
+      uses: peter-evans/create-or-update-comment@v3
+      with:
+        issue-number: ${{ context.issue.number }}
+        body: |
+          Hi @${{ github.event.pull_request.user.login }},
+
+          Thank you for contributing to Moto!
+
+          To show our thanks, we'd like to share some of the donations that we've received with you. PR's like this are the big reason that Moto is as successful as it is - so it's only fair that you, as a contributor, gets to share the spoils.
+
+          We've created a companion website with more information:
+          https://payments.getmoto.org/
+
+          This website is still in beta, but everything should work. Feel free to open a bug or discussion if you run into any problems:
+          https://github.com/getmoto/payments
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index 6243655d0..a741212a0 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -1197,7 +1197,7 @@
 
 ## comprehend
 <details>
-<summary>9% implemented</summary>
+<summary>13% implemented</summary>
 
 - [ ] batch_detect_dominant_language
 - [ ] batch_detect_entities
@@ -1235,9 +1235,9 @@
 - [ ] describe_topics_detection_job
 - [ ] detect_dominant_language
 - [ ] detect_entities
-- [ ] detect_key_phrases
-- [ ] detect_pii_entities
-- [ ] detect_sentiment
+- [X] detect_key_phrases
+- [X] detect_pii_entities
+- [X] detect_sentiment
 - [ ] detect_syntax
 - [ ] detect_targeted_sentiment
 - [ ] import_model
diff --git a/docs/docs/services/appsync.rst b/docs/docs/services/appsync.rst
index 861670d28..a68f03c94 100644
--- a/docs/docs/services/appsync.rst
+++ b/docs/docs/services/appsync.rst
@@ -54,7 +54,7 @@ appsync
 - [ ] get_domain_name
 - [ ] get_function
 - [X] get_graphql_api
-- [ ] get_introspection_schema
+- [X] get_introspection_schema
 - [ ] get_resolver
 - [X] get_schema_creation_status
 - [X] get_type
diff --git a/docs/docs/services/comprehend.rst b/docs/docs/services/comprehend.rst
index 47963412b..4b477a9c7 100644
--- a/docs/docs/services/comprehend.rst
+++ b/docs/docs/services/comprehend.rst
@@ -67,9 +67,9 @@ comprehend
 - [ ] describe_topics_detection_job
 - [ ] detect_dominant_language
 - [ ] detect_entities
-- [ ] detect_key_phrases
-- [ ] detect_pii_entities
-- [ ] detect_sentiment
+- [X] detect_key_phrases
+- [X] detect_pii_entities
+- [X] detect_sentiment
 - [ ] detect_syntax
 - [ ] detect_targeted_sentiment
 - [ ] import_model
diff --git a/moto/apigateway/models.py b/moto/apigateway/models.py
index bccbbdd7d..c70b26f9b 100644
--- a/moto/apigateway/models.py
+++ b/moto/apigateway/models.py
@@ -1573,6 +1573,10 @@ class APIGatewayBackend(BaseBackend):
                 validate_spec(api_doc)  # type: ignore[arg-type]
             except OpenAPIValidationError as e:
                 raise InvalidOpenAPIDocumentException(e)
+            except AttributeError:
+                # Call can fail in Python3.7 due to `typing_extensions 4.6.0` throwing an error
+                # Easiest to just ignore this for now - Py3.7 is EOL soon anyway
+                pass
         name = api_doc["info"]["title"]
         description = api_doc["info"]["description"]
         api = self.create_rest_api(name=name, description=description)
@@ -1644,6 +1648,10 @@ class APIGatewayBackend(BaseBackend):
                 validate_spec(api_doc)  # type: ignore[arg-type]
             except OpenAPIValidationError as e:
                 raise InvalidOpenAPIDocumentException(e)
+            except AttributeError:
+                # Call can fail in Python3.7 due to `typing_extensions 4.6.0` throwing an error
+                # Easiest to just ignore this for now - Py3.7 is EOL soon anyway
+                pass
 
         if mode == "overwrite":
             api = self.get_rest_api(function_id)
diff --git a/moto/appsync/exceptions.py b/moto/appsync/exceptions.py
index 289d5eb77..e387052f5 100644
--- a/moto/appsync/exceptions.py
+++ b/moto/appsync/exceptions.py
@@ -12,3 +12,16 @@ class GraphqlAPINotFound(AppSyncExceptions):
     def __init__(self, api_id: str):
         super().__init__("NotFoundException", f"GraphQL API {api_id} not found.")
         self.description = json.dumps({"message": self.message})
+
+
+class GraphQLSchemaException(AppSyncExceptions):
+    code = 400
+
+    def __init__(self, message: str):
+        super().__init__("GraphQLSchemaException", message)
+        self.description = json.dumps({"message": self.message})
+
+
+class BadRequestException(AppSyncExceptions):
+    def __init__(self, message: str):
+        super().__init__("BadRequestException", message)
diff --git a/moto/appsync/models.py b/moto/appsync/models.py
index 819c659e2..7718f1f5b 100644
--- a/moto/appsync/models.py
+++ b/moto/appsync/models.py
@@ -1,4 +1,5 @@
 import base64
+import json
 from datetime import timedelta, datetime, timezone
 from typing import Any, Dict, Iterable, List, Optional, Tuple
 from moto.core import BaseBackend, BackendDict, BaseModel
@@ -6,7 +7,34 @@ from moto.core.utils import unix_time
 from moto.moto_api._internal import mock_random
 from moto.utilities.tagging_service import TaggingService
 
-from .exceptions import GraphqlAPINotFound
+from .exceptions import GraphqlAPINotFound, GraphQLSchemaException, BadRequestException
+
+# AWS custom scalars and directives
+# https://github.com/dotansimha/graphql-code-generator/discussions/4311#discussioncomment-2921796
+AWS_CUSTOM_GRAPHQL = """scalar AWSTime
+scalar AWSDateTime
+scalar AWSTimestamp
+scalar AWSEmail
+scalar AWSJSON
+scalar AWSURL
+scalar AWSPhone
+scalar AWSIPAddress
+scalar BigInt
+scalar Double
+
+directive @aws_subscribe(mutations: [String!]!) on FIELD_DEFINITION
+
+# Allows transformer libraries to deprecate directive arguments.
+directive @deprecated(reason: String!) on INPUT_FIELD_DEFINITION | ENUM
+
+directive @aws_auth(cognito_groups: [String!]!) on FIELD_DEFINITION
+directive @aws_api_key on FIELD_DEFINITION | OBJECT
+directive @aws_iam on FIELD_DEFINITION | OBJECT
+directive @aws_oidc on FIELD_DEFINITION | OBJECT
+directive @aws_cognito_user_pools(
+  cognito_groups: [String!]
+) on FIELD_DEFINITION | OBJECT
+"""
 
 
 class GraphqlSchema(BaseModel):
@@ -49,6 +77,27 @@ class GraphqlSchema(BaseModel):
             self.status = "FAILED"
             self.parse_error = str(e)
 
+    def get_introspection_schema(self, format_: str, include_directives: bool) -> str:
+        from graphql import (
+            print_schema,
+            build_client_schema,
+            introspection_from_schema,
+            build_schema,
+        )
+
+        schema = build_schema(self.definition + AWS_CUSTOM_GRAPHQL)
+        introspection_data = introspection_from_schema(schema, descriptions=False)
+
+        if not include_directives:
+            introspection_data["__schema"]["directives"] = []
+
+        if format_ == "SDL":
+            return print_schema(build_client_schema(introspection_data))
+        elif format_ == "JSON":
+            return json.dumps(introspection_data)
+        else:
+            raise BadRequestException(message=f"Invalid format {format_} given")
+
 
 class GraphqlAPIKey(BaseModel):
     def __init__(self, description: str, expires: Optional[int]):
@@ -254,6 +303,15 @@ class AppSyncBackend(BaseBackend):
             raise GraphqlAPINotFound(api_id)
         return self.graphql_apis[api_id]
 
+    def get_graphql_schema(self, api_id: str) -> GraphqlSchema:
+        graphql_api = self.get_graphql_api(api_id)
+        if not graphql_api.graphql_schema:
+            # When calling get_introspetion_schema without a graphql schema
+            # the response GraphQLSchemaException exception includes InvalidSyntaxError
+            # in the message. This might not be the case for other methods.
+            raise GraphQLSchemaException(message="InvalidSyntaxError")
+        return graphql_api.graphql_schema
+
     def delete_graphql_api(self, api_id: str) -> None:
         self.graphql_apis.pop(api_id)
 
diff --git a/moto/appsync/responses.py b/moto/appsync/responses.py
index 0a51bf8b1..c815e6d76 100644
--- a/moto/appsync/responses.py
+++ b/moto/appsync/responses.py
@@ -69,6 +69,11 @@ class AppSyncResponse(BaseResponse):
         if request.method == "GET":
             return self.get_type()
 
+    def schema(self, request: Any, full_url: str, headers: Any) -> TYPE_RESPONSE:  # type: ignore[return]
+        self.setup_class(request, full_url, headers)
+        if request.method == "GET":
+            return self.get_introspection_schema()
+
     def create_graphql_api(self) -> TYPE_RESPONSE:
         params = json.loads(self.body)
         name = params.get("name")
@@ -230,3 +235,19 @@ class AppSyncResponse(BaseResponse):
             api_id=api_id, type_name=type_name, type_format=type_format
         )
         return 200, {}, json.dumps(dict(type=graphql_type))
+
+    def get_introspection_schema(self) -> TYPE_RESPONSE:  # type: ignore[return]
+        api_id = self.path.split("/")[-2]
+        format_ = self.querystring.get("format")[0]  # type: ignore[index]
+        if self.querystring.get("includeDirectives"):
+            include_directives = (
+                self.querystring.get("includeDirectives")[0].lower() == "true"  # type: ignore[index]
+            )
+        else:
+            include_directives = True
+        graphql_schema = self.appsync_backend.get_graphql_schema(api_id=api_id)
+
+        schema = graphql_schema.get_introspection_schema(
+            format_=format_, include_directives=include_directives
+        )
+        return 200, {}, schema
diff --git a/moto/appsync/urls.py b/moto/appsync/urls.py
index 1b078f720..0b04b48d2 100644
--- a/moto/appsync/urls.py
+++ b/moto/appsync/urls.py
@@ -15,6 +15,7 @@ url_paths = {
     "{0}/v1/apis/(?P<api_id>[^/]+)/apikeys$": response.api_key,
     "{0}/v1/apis/(?P<api_id>[^/]+)/apikeys/(?P<api_key_id>[^/]+)$": response.api_key_individual,
     "{0}/v1/apis/(?P<api_id>[^/]+)/schemacreation$": response.schemacreation,
+    "{0}/v1/apis/(?P<api_id>[^/]+)/schema$": response.schema,
     "{0}/v1/tags/(?P<resource_arn>.+)$": response.tags,
     "{0}/v1/tags/(?P<resource_arn_pt1>.+)/(?P<resource_arn_pt2>.+)$": response.tags,
     "{0}/v1/apis/(?P<api_id>[^/]+)/types/(?P<type_name>.+)$": response.types,
diff --git a/moto/comprehend/exceptions.py b/moto/comprehend/exceptions.py
index c4031b203..0530214af 100644
--- a/moto/comprehend/exceptions.py
+++ b/moto/comprehend/exceptions.py
@@ -1,5 +1,6 @@
 """Exceptions raised by the comprehend service."""
 from moto.core.exceptions import JsonRESTError
+from typing import List
 
 
 class ResourceNotFound(JsonRESTError):
@@ -8,3 +9,22 @@ class ResourceNotFound(JsonRESTError):
             "ResourceNotFoundException",
             "RESOURCE_NOT_FOUND: Could not find specified resource.",
         )
+
+
+class DetectPIIValidationException(JsonRESTError):
+    def __init__(self, language: str, all_languages: List[str]) -> None:
+        all_languages_str = str(all_languages).replace("'", "")
+        super().__init__(
+            "ValidationException",
+            f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+            f"Member must satisfy enum value set: {all_languages_str}",
+        )
+
+
+class TextSizeLimitExceededException(JsonRESTError):
+    def __init__(self, size: int) -> None:
+        super().__init__(
+            "TextSizeLimitExceededException",
+            "Input text size exceeds limit. Max length of request text allowed is 100000 bytes while in "
+            f"this request the text size is {size} bytes",
+        )
diff --git a/moto/comprehend/models.py b/moto/comprehend/models.py
index c33b72b4c..c06dac041 100644
--- a/moto/comprehend/models.py
+++ b/moto/comprehend/models.py
@@ -2,9 +2,62 @@
 
 from moto.core import BaseBackend, BackendDict, BaseModel
 from moto.utilities.tagging_service import TaggingService
-from .exceptions import ResourceNotFound
+from .exceptions import (
+    ResourceNotFound,
+    DetectPIIValidationException,
+    TextSizeLimitExceededException,
+)
 from typing import Any, Dict, List, Iterable
 
+CANNED_DETECT_RESPONSE = [
+    {
+        "Score": 0.9999890923500061,
+        "Type": "NAME",
+        "BeginOffset": 50,
+        "EndOffset": 58,
+    },
+    {
+        "Score": 0.9999966621398926,
+        "Type": "EMAIL",
+        "BeginOffset": 230,
+        "EndOffset": 259,
+    },
+    {
+        "Score": 0.9999954700469971,
+        "Type": "BANK_ACCOUNT_NUMBER",
+        "BeginOffset": 334,
+        "EndOffset": 349,
+    },
+]
+
+CANNED_PHRASES_RESPONSE = [
+    {
+        "Score": 0.9999890923500061,
+        "BeginOffset": 50,
+        "EndOffset": 58,
+    },
+    {
+        "Score": 0.9999966621398926,
+        "BeginOffset": 230,
+        "EndOffset": 259,
+    },
+    {
+        "Score": 0.9999954700469971,
+        "BeginOffset": 334,
+        "EndOffset": 349,
+    },
+]
+
+CANNED_SENTIMENT_RESPONSE = {
+    "Sentiment": "NEUTRAL",
+    "SentimentScore": {
+        "Positive": 0.008101312443614006,
+        "Negative": 0.0002824589901138097,
+        "Neutral": 0.9916020035743713,
+        "Mixed": 1.4156351426208857e-05,
+    },
+}
+
 
 class EntityRecognizer(BaseModel):
     def __init__(
@@ -53,6 +106,24 @@ class EntityRecognizer(BaseModel):
 class ComprehendBackend(BaseBackend):
     """Implementation of Comprehend APIs."""
 
+    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/comprehend/client/detect_key_phrases.html
+    detect_key_phrases_languages = [
+        "ar",
+        "hi",
+        "ko",
+        "zh-TW",
+        "ja",
+        "zh",
+        "de",
+        "pt",
+        "en",
+        "it",
+        "fr",
+        "es",
+    ]
+    # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/comprehend/client/detect_pii_entities.html
+    detect_pii_entities_languages = ["en"]
+
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
         self.recognizers: Dict[str, EntityRecognizer] = dict()
@@ -130,5 +201,35 @@ class ComprehendBackend(BaseBackend):
     def untag_resource(self, resource_arn: str, tag_keys: List[str]) -> None:
         self.tagger.untag_resource_using_names(resource_arn, tag_keys)
 
+    def detect_pii_entities(self, text: str, language: str) -> List[Dict[str, Any]]:
+        if language not in self.detect_pii_entities_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_pii_entities_languages
+            )
+        text_size = len(text)
+        if text_size > 100000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_DETECT_RESPONSE
+
+    def detect_key_phrases(self, text: str, language: str) -> List[Dict[str, Any]]:
+        if language not in self.detect_key_phrases_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_key_phrases_languages
+            )
+        text_size = len(text)
+        if text_size > 100000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_PHRASES_RESPONSE
+
+    def detect_sentiment(self, text: str, language: str) -> Dict[str, Any]:
+        if language not in self.detect_key_phrases_languages:
+            raise DetectPIIValidationException(
+                language, self.detect_key_phrases_languages
+            )
+        text_size = len(text)
+        if text_size > 5000:
+            raise TextSizeLimitExceededException(text_size)
+        return CANNED_SENTIMENT_RESPONSE
+
 
 comprehend_backends = BackendDict(ComprehendBackend, "comprehend")
diff --git a/moto/comprehend/responses.py b/moto/comprehend/responses.py
index 6e1d7b0b2..d439d853d 100644
--- a/moto/comprehend/responses.py
+++ b/moto/comprehend/responses.py
@@ -95,3 +95,24 @@ class ComprehendResponse(BaseResponse):
         tag_keys = params.get("TagKeys")
         self.comprehend_backend.untag_resource(resource_arn, tag_keys)
         return "{}"
+
+    def detect_pii_entities(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_pii_entities(text, language)
+        return json.dumps(dict(Entities=resp))
+
+    def detect_key_phrases(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_key_phrases(text, language)
+        return json.dumps(dict(KeyPhrases=resp))
+
+    def detect_sentiment(self) -> str:
+        params = json.loads(self.body)
+        text = params.get("Text")
+        language = params.get("LanguageCode")
+        resp = self.comprehend_backend.detect_sentiment(text, language)
+        return json.dumps(resp)
diff --git a/moto/core/exceptions.py b/moto/core/exceptions.py
index 579024f7c..5c0e306c2 100644
--- a/moto/core/exceptions.py
+++ b/moto/core/exceptions.py
@@ -84,6 +84,11 @@ class RESTError(HTTPException):
     ) -> str:
         return self.description  # type: ignore[return-value]
 
+    def to_json(self) -> "JsonRESTError":
+        err = JsonRESTError(error_type=self.error_type, message=self.message)
+        err.code = self.code
+        return err
+
 
 class DryRunClientError(RESTError):
     code = 412
diff --git a/moto/dynamodb/parsing/partiql.py b/moto/dynamodb/parsing/partiql.py
index 78a92c345..b148921f1 100644
--- a/moto/dynamodb/parsing/partiql.py
+++ b/moto/dynamodb/parsing/partiql.py
@@ -1,4 +1,7 @@
-from typing import Any, Dict, List
+from typing import Any, Dict, List, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from py_partiql_parser import QueryMetadata
 
 
 def query(
@@ -9,7 +12,7 @@ def query(
     return DynamoDBStatementParser(source_data).parse(statement, parameters)
 
 
-def get_query_metadata(statement: str) -> Any:
+def get_query_metadata(statement: str) -> "QueryMetadata":
     from py_partiql_parser import DynamoDBStatementParser
 
     return DynamoDBStatementParser.get_query_metadata(query=statement)
diff --git a/moto/ec2/models/key_pairs.py b/moto/ec2/models/key_pairs.py
index b2dab1235..b480b037d 100644
--- a/moto/ec2/models/key_pairs.py
+++ b/moto/ec2/models/key_pairs.py
@@ -1,4 +1,5 @@
 from typing import Any, Dict, List
+from datetime import datetime
 
 from moto.core import BaseModel
 from ..exceptions import (
@@ -14,6 +15,7 @@ from ..utils import (
     generic_filter,
     random_key_pair_id,
 )
+from moto.core.utils import iso_8601_datetime_with_milliseconds
 
 
 class KeyPair(BaseModel):
@@ -22,6 +24,11 @@ class KeyPair(BaseModel):
         self.name = name
         self.fingerprint = fingerprint
         self.material = material
+        self.create_time = datetime.utcnow()
+
+    @property
+    def created_iso_8601(self) -> str:
+        return iso_8601_datetime_with_milliseconds(self.create_time)
 
     def get_filter_value(self, filter_name: str) -> str:
         if filter_name == "key-name":
diff --git a/moto/ec2/responses/key_pairs.py b/moto/ec2/responses/key_pairs.py
index 343f20bde..65f6d2842 100644
--- a/moto/ec2/responses/key_pairs.py
+++ b/moto/ec2/responses/key_pairs.py
@@ -37,6 +37,7 @@ DESCRIBE_KEY_PAIRS_RESPONSE = """<DescribeKeyPairsResponse xmlns="http://ec2.ama
     <keySet>
     {% for keypair in keypairs %}
       <item>
+           <createTime>{{ keypair.created_iso_8601 }}</createTime>
            <keyPairId>{{ keypair.id }}</keyPairId>
            <keyName>{{ keypair.name }}</keyName>
            <keyFingerprint>{{ keypair.fingerprint }}</keyFingerprint>
diff --git a/moto/s3/models.py b/moto/s3/models.py
index c7f4e9527..ae744e532 100644
--- a/moto/s3/models.py
+++ b/moto/s3/models.py
@@ -2486,9 +2486,14 @@ class S3Backend(BaseBackend, CloudWatchMetricProvider):
 
             use_headers = input_details["CSV"].get("FileHeaderInfo", "") == "USE"
             query_input = csv_to_json(query_input, use_headers)
+        query_result = parse_query(query_input, select_query)
+        from py_partiql_parser import SelectEncoder
+
         return [
-            json.dumps(x, indent=None, separators=(",", ":")).encode("utf-8")
-            for x in parse_query(query_input, select_query)
+            json.dumps(x, indent=None, separators=(",", ":"), cls=SelectEncoder).encode(
+                "utf-8"
+            )
+            for x in query_result
         ]
 
 
diff --git a/moto/sqs/models.py b/moto/sqs/models.py
index 7d775d274..308afd4ee 100644
--- a/moto/sqs/models.py
+++ b/moto/sqs/models.py
@@ -162,6 +162,10 @@ class Message(BaseModel):
     def body(self) -> str:
         return escape(self._body).replace('"', "&quot;").replace("\r", "&#xD;")
 
+    @property
+    def original_body(self) -> str:
+        return self._body
+
     def mark_sent(self, delay_seconds: Optional[int] = None) -> None:
         self.sent_timestamp = int(unix_time_millis())  # type: ignore
         if delay_seconds:
@@ -1030,7 +1034,7 @@ class SQSBackend(BaseBackend):
                 errors.append(
                     {
                         "Id": receipt_and_id["msg_user_id"],
-                        "SenderFault": "true",
+                        "SenderFault": True,
                         "Code": "ReceiptHandleIsInvalid",
                         "Message": f'The input receipt handle "{receipt_and_id["receipt_handle"]}" is not a valid receipt handle.',
                     }
diff --git a/moto/sqs/responses.py b/moto/sqs/responses.py
index 56d1d93e1..32ed869d1 100644
--- a/moto/sqs/responses.py
+++ b/moto/sqs/responses.py
@@ -1,11 +1,18 @@
+import json
 import re
-from typing import Any, Dict, Optional, Tuple, Union
+from functools import wraps
+from typing import Any, Callable, Dict, Optional, Union
 
 from moto.core.common_types import TYPE_RESPONSE
-from moto.core.exceptions import RESTError
+from moto.core.exceptions import JsonRESTError
 from moto.core.responses import BaseResponse
-from moto.core.utils import underscores_to_camelcase, camelcase_to_pascal
+from moto.core.utils import (
+    underscores_to_camelcase,
+    camelcase_to_pascal,
+    camelcase_to_underscores,
+)
 from moto.utilities.aws_headers import amz_crc32, amzn_request_id
+from moto.utilities.constants import JSON_TYPES
 from urllib.parse import urlparse
 
 from .constants import (
@@ -14,12 +21,36 @@ from .constants import (
     MAXIMUM_VISIBILITY_TIMEOUT,
 )
 from .exceptions import (
+    RESTError,
     EmptyBatchRequest,
     InvalidAttributeName,
     BatchEntryIdsNotDistinct,
 )
 from .models import sqs_backends, SQSBackend
-from .utils import parse_message_attributes, extract_input_message_attributes
+from .utils import (
+    parse_message_attributes,
+    extract_input_message_attributes,
+    validate_message_attributes,
+)
+
+
+def jsonify_error(
+    method: Callable[["SQSResponse"], Union[str, TYPE_RESPONSE]]
+) -> Callable[["SQSResponse"], Union[str, TYPE_RESPONSE]]:
+    """
+    The decorator to convert an RESTError to JSON, if necessary
+    """
+
+    @wraps(method)
+    def f(self: "SQSResponse") -> Union[str, TYPE_RESPONSE]:
+        try:
+            return method(self)
+        except RESTError as e:
+            if self.is_json():
+                raise e.to_json()
+            raise e
+
+    return f
 
 
 class SQSResponse(BaseResponse):
@@ -29,27 +60,52 @@ class SQSResponse(BaseResponse):
     def __init__(self) -> None:
         super().__init__(service_name="sqs")
 
+    def is_json(self) -> bool:
+        """
+        botocore 1.29.127 changed the wire-format to SQS
+        This means three things:
+         - The Content-Type is set to JSON
+         - The input-parameters are in different formats
+         - The output is in a different format
+
+         The change has been reverted for now, but it will be re-introduced later:
+         https://github.com/boto/botocore/pull/2931
+        """
+        return self.headers.get("Content-Type") in JSON_TYPES
+
     @property
     def sqs_backend(self) -> SQSBackend:
         return sqs_backends[self.current_account][self.region]
 
     @property
     def attribute(self) -> Any:  # type: ignore[misc]
-        if not hasattr(self, "_attribute"):
-            self._attribute = self._get_map_prefix(
-                "Attribute", key_end=".Name", value_end=".Value"
-            )
-        return self._attribute
+        try:
+            assert self.is_json()
+            return json.loads(self.body).get("Attributes", {})
+        except:  # noqa: E722 Do not use bare except
+            if not hasattr(self, "_attribute"):
+                self._attribute = self._get_map_prefix(
+                    "Attribute", key_end=".Name", value_end=".Value"
+                )
+            return self._attribute
 
     @property
     def tags(self) -> Dict[str, str]:
         if not hasattr(self, "_tags"):
-            self._tags = self._get_map_prefix("Tag", key_end=".Key", value_end=".Value")
+            if self.is_json():
+                self._tags = self._get_param("tags")
+            else:
+                self._tags = self._get_map_prefix(
+                    "Tag", key_end=".Key", value_end=".Value"
+                )
         return self._tags
 
     def _get_queue_name(self) -> str:
         try:
-            queue_url = self.querystring.get("QueueUrl")[0]  # type: ignore
+            if self.is_json():
+                queue_url = self._get_param("QueueUrl")
+            else:
+                queue_url = self.querystring.get("QueueUrl")[0]  # type: ignore
             if queue_url.startswith("http://") or queue_url.startswith("https://"):
                 return queue_url.split("/")[-1]
             else:
@@ -67,7 +123,10 @@ class SQSResponse(BaseResponse):
         if timeout is not None:
             visibility_timeout = int(timeout)
         else:
-            visibility_timeout = int(self.querystring.get("VisibilityTimeout")[0])  # type: ignore
+            if self.is_json():
+                visibility_timeout = self._get_param("VisibilityTimeout")
+            else:
+                visibility_timeout = int(self.querystring.get("VisibilityTimeout")[0])  # type: ignore
 
         if visibility_timeout > MAXIMUM_VISIBILITY_TIMEOUT:
             raise ValueError
@@ -85,45 +144,66 @@ class SQSResponse(BaseResponse):
             return 404, headers, response
         return status_code, headers, body
 
-    def _error(
-        self, code: str, message: str, status: int = 400
-    ) -> Tuple[str, Dict[str, int]]:
+    def _error(self, code: str, message: str, status: int = 400) -> TYPE_RESPONSE:
+        if self.is_json():
+            err = JsonRESTError(error_type=code, message=message)
+            err.code = status
+            raise err
         template = self.response_template(ERROR_TEMPLATE)
-        return template.render(code=code, message=message), dict(status=status)
+        return status, {"status": status}, template.render(code=code, message=message)
 
+    @jsonify_error
     def create_queue(self) -> str:
         request_url = urlparse(self.uri)
         queue_name = self._get_param("QueueName")
 
         queue = self.sqs_backend.create_queue(queue_name, self.tags, **self.attribute)
 
+        if self.is_json():
+            return json.dumps({"QueueUrl": queue.url(request_url)})
+
         template = self.response_template(CREATE_QUEUE_RESPONSE)
         return template.render(queue_url=queue.url(request_url))
 
+    @jsonify_error
     def get_queue_url(self) -> str:
         request_url = urlparse(self.uri)
         queue_name = self._get_param("QueueName")
 
         queue = self.sqs_backend.get_queue_url(queue_name)
 
+        if self.is_json():
+            return json.dumps({"QueueUrl": queue.url(request_url)})
+
         template = self.response_template(GET_QUEUE_URL_RESPONSE)
         return template.render(queue_url=queue.url(request_url))
 
+    @jsonify_error
     def list_queues(self) -> str:
         request_url = urlparse(self.uri)
         queue_name_prefix = self._get_param("QueueNamePrefix")
         queues = self.sqs_backend.list_queues(queue_name_prefix)
+
+        if self.is_json():
+            if queues:
+                return json.dumps(
+                    {"QueueUrls": [queue.url(request_url) for queue in queues]}
+                )
+            else:
+                return "{}"
+
         template = self.response_template(LIST_QUEUES_RESPONSE)
         return template.render(queues=queues, request_url=request_url)
 
-    def change_message_visibility(self) -> Union[str, Tuple[str, Dict[str, int]]]:
+    @jsonify_error
+    def change_message_visibility(self) -> Union[str, TYPE_RESPONSE]:
         queue_name = self._get_queue_name()
         receipt_handle = self._get_param("ReceiptHandle")
 
         try:
             visibility_timeout = self._get_validated_visibility_timeout()
         except ValueError:
-            return ERROR_MAX_VISIBILITY_TIMEOUT_RESPONSE, dict(status=400)
+            return 400, {}, ERROR_MAX_VISIBILITY_TIMEOUT_RESPONSE
 
         self.sqs_backend.change_message_visibility(
             queue_name=queue_name,
@@ -131,36 +211,70 @@ class SQSResponse(BaseResponse):
             visibility_timeout=visibility_timeout,
         )
 
+        if self.is_json():
+            return "{}"
+
         template = self.response_template(CHANGE_MESSAGE_VISIBILITY_RESPONSE)
         return template.render()
 
+    @jsonify_error
     def change_message_visibility_batch(self) -> str:
         queue_name = self._get_queue_name()
-        entries = self._get_list_prefix("ChangeMessageVisibilityBatchRequestEntry")
+        if self.is_json():
+            entries = [
+                {camelcase_to_underscores(key): value for key, value in entr.items()}
+                for entr in self._get_param("Entries")
+            ]
+        else:
+            entries = self._get_list_prefix("ChangeMessageVisibilityBatchRequestEntry")
 
         success, error = self.sqs_backend.change_message_visibility_batch(
             queue_name, entries
         )
+        if self.is_json():
+            return json.dumps(
+                {"Successful": [{"Id": _id} for _id in success], "Failed": error}
+            )
 
         template = self.response_template(CHANGE_MESSAGE_VISIBILITY_BATCH_RESPONSE)
         return template.render(success=success, errors=error)
 
+    @jsonify_error
     def get_queue_attributes(self) -> str:
         queue_name = self._get_queue_name()
 
-        if self.querystring.get("AttributeNames"):
+        if not self.is_json() and self.querystring.get("AttributeNames"):
             raise InvalidAttributeName("")
 
-        # if connecting to AWS via boto, then 'AttributeName' is just a normal parameter
-        attribute_names = self._get_multi_param(
-            "AttributeName"
-        ) or self.querystring.get("AttributeName")
+        if self.is_json():
+            attribute_names = self._get_param("AttributeNames")
+            if attribute_names == [] or (attribute_names and "" in attribute_names):
+                raise InvalidAttributeName("")
+        else:
+            # if connecting to AWS via boto, then 'AttributeName' is just a normal parameter
+            attribute_names = self._get_multi_param(
+                "AttributeName"
+            ) or self.querystring.get("AttributeName")
 
         attributes = self.sqs_backend.get_queue_attributes(queue_name, attribute_names)  # type: ignore
 
+        if self.is_json():
+            if len(attributes) == 0:
+                return "{}"
+            return json.dumps(
+                {
+                    "Attributes": {
+                        key: str(value)
+                        for key, value in attributes.items()
+                        if value is not None
+                    }
+                }
+            )
+
         template = self.response_template(GET_QUEUE_ATTRIBUTES_RESPONSE)
         return template.render(attributes=attributes)
 
+    @jsonify_error
     def set_queue_attributes(self) -> str:
         # TODO validate self.get_param('QueueUrl')
         attribute = self.attribute
@@ -177,6 +291,7 @@ class SQSResponse(BaseResponse):
 
         return SET_QUEUE_ATTRIBUTE_RESPONSE
 
+    @jsonify_error
     def delete_queue(self) -> str:
         # TODO validate self.get_param('QueueUrl')
         queue_name = self._get_queue_name()
@@ -186,19 +301,32 @@ class SQSResponse(BaseResponse):
         template = self.response_template(DELETE_QUEUE_RESPONSE)
         return template.render()
 
-    def send_message(self) -> Union[str, Tuple[str, Dict[str, int]]]:
+    @jsonify_error
+    def send_message(self) -> Union[str, TYPE_RESPONSE]:
         message = self._get_param("MessageBody")
         delay_seconds = int(self._get_param("DelaySeconds", 0))
         message_group_id = self._get_param("MessageGroupId")
         message_dedupe_id = self._get_param("MessageDeduplicationId")
 
         if len(message) > MAXIMUM_MESSAGE_LENGTH:
-            return ERROR_TOO_LONG_RESPONSE, dict(status=400)
+            return self._error(
+                "InvalidParameterValue",
+                message="One or more parameters are invalid. Reason: Message must be shorter than 262144 bytes.",
+            )
 
-        message_attributes = parse_message_attributes(self.querystring)
-        system_message_attributes = parse_message_attributes(
-            self.querystring, key="MessageSystemAttribute"
-        )
+        if self.is_json():
+            message_attributes = self._get_param("MessageAttributes")
+            self.normalize_json_msg_attributes(message_attributes)
+        else:
+            message_attributes = parse_message_attributes(self.querystring)
+
+        if self.is_json():
+            system_message_attributes = self._get_param("MessageSystemAttributes")
+            self.normalize_json_msg_attributes(system_message_attributes)
+        else:
+            system_message_attributes = parse_message_attributes(
+                self.querystring, key="MessageSystemAttribute"
+            )
 
         queue_name = self._get_queue_name()
 
@@ -215,9 +343,30 @@ class SQSResponse(BaseResponse):
         except RESTError as err:
             return self._error(err.error_type, err.message)
 
+        if self.is_json():
+            resp = {
+                "MD5OfMessageBody": message.body_md5,
+                "MessageId": message.id,
+            }
+            if len(message.message_attributes) > 0:
+                resp["MD5OfMessageAttributes"] = message.attribute_md5
+            return json.dumps(resp)
+
         template = self.response_template(SEND_MESSAGE_RESPONSE)
         return template.render(message=message, message_attributes=message_attributes)
 
+    def normalize_json_msg_attributes(self, message_attributes: Dict[str, Any]) -> None:
+        for key, value in (message_attributes or {}).items():
+            if "BinaryValue" in value:
+                message_attributes[key]["binary_value"] = value.pop("BinaryValue")
+            if "StringValue" in value:
+                message_attributes[key]["string_value"] = value.pop("StringValue")
+            if "DataType" in value:
+                message_attributes[key]["data_type"] = value.pop("DataType")
+
+        validate_message_attributes(message_attributes)
+
+    @jsonify_error
     def send_message_batch(self) -> str:
         """
         The querystring comes like this
@@ -234,39 +383,33 @@ class SQSResponse(BaseResponse):
 
         self.sqs_backend.get_queue(queue_name)
 
-        if self.querystring.get("Entries"):
+        if not self.is_json() and self.querystring.get("Entries"):
             raise EmptyBatchRequest()
-
-        entries = {}
-        for key, value in self.querystring.items():
-            match = re.match(r"^SendMessageBatchRequestEntry\.(\d+)\.Id", key)
-            if match:
-                index = match.group(1)
-
-                message_attributes = parse_message_attributes(
-                    self.querystring,
-                    base=f"SendMessageBatchRequestEntry.{index}.",
+        if self.is_json():
+            entries = {
+                str(idx): entry for idx, entry in enumerate(self._get_param("Entries"))
+            }
+        else:
+            entries = {
+                str(idx): entry
+                for idx, entry in enumerate(
+                    self._get_multi_param("SendMessageBatchRequestEntry")
                 )
-
-                entries[index] = {
-                    "Id": value[0],
-                    "MessageBody": self.querystring.get(  # type: ignore
-                        f"SendMessageBatchRequestEntry.{index}.MessageBody"
-                    )[0],
-                    "DelaySeconds": self.querystring.get(
-                        f"SendMessageBatchRequestEntry.{index}.DelaySeconds",
-                        [None],
-                    )[0],
-                    "MessageAttributes": message_attributes,
-                    "MessageGroupId": self.querystring.get(
-                        f"SendMessageBatchRequestEntry.{index}.MessageGroupId",
-                        [None],
-                    )[0],
-                    "MessageDeduplicationId": self.querystring.get(
-                        f"SendMessageBatchRequestEntry.{index}.MessageDeduplicationId",
-                        [None],
-                    )[0],
-                }
+            }
+            for entry in entries.values():
+                if "MessageAttribute" in entry:
+                    entry["MessageAttributes"] = {
+                        val["Name"]: val["Value"]
+                        for val in entry.pop("MessageAttribute")
+                    }
+
+        for entry in entries.values():
+            if "MessageAttributes" in entry:
+                self.normalize_json_msg_attributes(entry["MessageAttributes"])
+            else:
+                entry["MessageAttributes"] = {}
+            if "DelaySeconds" not in entry:
+                entry["DelaySeconds"] = None
 
         if entries == {}:
             raise EmptyBatchRequest()
@@ -286,16 +429,38 @@ class SQSResponse(BaseResponse):
                 }
             )
 
+        if self.is_json():
+            resp: Dict[str, Any] = {"Successful": [], "Failed": errors}
+            for msg in messages:
+                msg_dict = {
+                    "Id": msg.user_id,  # type: ignore
+                    "MessageId": msg.id,
+                    "MD5OfMessageBody": msg.body_md5,
+                }
+                if len(msg.message_attributes) > 0:
+                    msg_dict["MD5OfMessageAttributes"] = msg.attribute_md5
+                resp["Successful"].append(msg_dict)
+            return json.dumps(resp)
+
         template = self.response_template(SEND_MESSAGE_BATCH_RESPONSE)
         return template.render(messages=messages, errors=errors)
 
+    @jsonify_error
     def delete_message(self) -> str:
         queue_name = self._get_queue_name()
-        receipt_handle = self.querystring.get("ReceiptHandle")[0]  # type: ignore
+        if self.is_json():
+            receipt_handle = self._get_param("ReceiptHandle")
+        else:
+            receipt_handle = self.querystring.get("ReceiptHandle")[0]  # type: ignore
         self.sqs_backend.delete_message(queue_name, receipt_handle)
+
+        if self.is_json():
+            return "{}"
+
         template = self.response_template(DELETE_MESSAGE_RESPONSE)
         return template.render()
 
+    @jsonify_error
     def delete_message_batch(self) -> str:
         """
         The querystring comes like this
@@ -308,21 +473,17 @@ class SQSResponse(BaseResponse):
         """
         queue_name = self._get_queue_name()
 
-        receipts = []
-
-        for index in range(1, 11):
-            # Loop through looking for messages
-            receipt_key = f"DeleteMessageBatchRequestEntry.{index}.ReceiptHandle"
-            receipt_handle = self.querystring.get(receipt_key)
-            if not receipt_handle:
-                # Found all messages
-                break
+        if self.is_json():
+            receipts = self._get_param("Entries")
+        else:
+            receipts = self._get_multi_param("DeleteMessageBatchRequestEntry")
 
-            message_user_id_key = f"DeleteMessageBatchRequestEntry.{index}.Id"
-            message_user_id = self.querystring.get(message_user_id_key)[0]  # type: ignore
-            receipts.append(
-                {"receipt_handle": receipt_handle[0], "msg_user_id": message_user_id}
-            )
+        for r in receipts:
+            for key in list(r.keys()):
+                if key == "Id":
+                    r["msg_user_id"] = r.pop(key)
+                else:
+                    r[camelcase_to_underscores(key)] = r.pop(key)
 
         receipt_seen = set()
         for receipt_and_id in receipts:
@@ -333,27 +494,45 @@ class SQSResponse(BaseResponse):
 
         success, errors = self.sqs_backend.delete_message_batch(queue_name, receipts)
 
+        if self.is_json():
+            return json.dumps(
+                {"Successful": [{"Id": _id} for _id in success], "Failed": errors}
+            )
+
         template = self.response_template(DELETE_MESSAGE_BATCH_RESPONSE)
         return template.render(success=success, errors=errors)
 
+    @jsonify_error
     def purge_queue(self) -> str:
         queue_name = self._get_queue_name()
         self.sqs_backend.purge_queue(queue_name)
         template = self.response_template(PURGE_QUEUE_RESPONSE)
         return template.render()
 
-    def receive_message(self) -> Union[str, Tuple[str, Dict[str, int]]]:
+    @jsonify_error
+    def receive_message(self) -> Union[str, TYPE_RESPONSE]:
         queue_name = self._get_queue_name()
-        message_attributes = self._get_multi_param("message_attributes")
+        if self.is_json():
+            message_attributes = self._get_param("MessageAttributeNames")
+        else:
+            message_attributes = self._get_multi_param("message_attributes")
         if not message_attributes:
             message_attributes = extract_input_message_attributes(self.querystring)
 
-        attribute_names = self._get_multi_param("AttributeName")
+        if self.is_json():
+            attribute_names = self._get_param("AttributeNames", [])
+        else:
+            attribute_names = self._get_multi_param("AttributeName")
 
         queue = self.sqs_backend.get_queue(queue_name)
 
         try:
-            message_count = int(self.querystring.get("MaxNumberOfMessages")[0])  # type: ignore
+            if self.is_json():
+                message_count = self._get_param(
+                    "MaxNumberOfMessages", DEFAULT_RECEIVED_MESSAGES
+                )
+            else:
+                message_count = int(self.querystring.get("MaxNumberOfMessages")[0])  # type: ignore
         except TypeError:
             message_count = DEFAULT_RECEIVED_MESSAGES
 
@@ -367,7 +546,10 @@ class SQSResponse(BaseResponse):
             )
 
         try:
-            wait_time = int(self.querystring.get("WaitTimeSeconds")[0])  # type: ignore
+            if self.is_json():
+                wait_time = int(self._get_param("WaitTimeSeconds"))
+            else:
+                wait_time = int(self.querystring.get("WaitTimeSeconds")[0])  # type: ignore
         except TypeError:
             wait_time = int(queue.receive_message_wait_time_seconds)  # type: ignore
 
@@ -385,7 +567,7 @@ class SQSResponse(BaseResponse):
         except TypeError:
             visibility_timeout = queue.visibility_timeout  # type: ignore
         except ValueError:
-            return ERROR_MAX_VISIBILITY_TIMEOUT_RESPONSE, dict(status=400)
+            return 400, {}, ERROR_MAX_VISIBILITY_TIMEOUT_RESPONSE
 
         messages = self.sqs_backend.receive_message(
             queue_name, message_count, wait_time, visibility_timeout, message_attributes
@@ -406,22 +588,98 @@ class SQSResponse(BaseResponse):
             if any(x in ["All", pascalcase_name] for x in attribute_names):
                 attributes[attribute] = True
 
+        if self.is_json():
+            msgs = []
+            for message in messages:
+                msg: Dict[str, Any] = {
+                    "MessageId": message.id,
+                    "ReceiptHandle": message.receipt_handle,
+                    "MD5OfBody": message.body_md5,
+                    "Body": message.original_body,
+                    "Attributes": {},
+                    "MessageAttributes": {},
+                }
+                if len(message.message_attributes) > 0:
+                    msg["MD5OfMessageAttributes"] = message.attribute_md5
+                if attributes["sender_id"]:
+                    msg["Attributes"]["SenderId"] = message.sender_id
+                if attributes["sent_timestamp"]:
+                    msg["Attributes"]["SentTimestamp"] = str(message.sent_timestamp)
+                if attributes["approximate_receive_count"]:
+                    msg["Attributes"]["ApproximateReceiveCount"] = str(
+                        message.approximate_receive_count
+                    )
+                if attributes["approximate_first_receive_timestamp"]:
+                    msg["Attributes"]["ApproximateFirstReceiveTimestamp"] = str(
+                        message.approximate_first_receive_timestamp
+                    )
+                if attributes["message_deduplication_id"]:
+                    msg["Attributes"][
+                        "MessageDeduplicationId"
+                    ] = message.deduplication_id
+                if attributes["message_group_id"] and message.group_id is not None:
+                    msg["Attributes"]["MessageGroupId"] = message.group_id
+                if message.system_attributes and message.system_attributes.get(
+                    "AWSTraceHeader"
+                ):
+                    msg["Attributes"]["AWSTraceHeader"] = message.system_attributes[
+                        "AWSTraceHeader"
+                    ].get("string_value")
+                if (
+                    attributes["sequence_number"]
+                    and message.sequence_number is not None
+                ):
+                    msg["Attributes"]["SequenceNumber"] = message.sequence_number
+                for name, value in message.message_attributes.items():
+                    msg["MessageAttributes"][name] = {"DataType": value["data_type"]}
+                    if "Binary" in value["data_type"]:
+                        msg["MessageAttributes"][name]["BinaryValue"] = value[
+                            "binary_value"
+                        ]
+                    else:
+                        msg["MessageAttributes"][name]["StringValue"] = value[
+                            "string_value"
+                        ]
+
+                if len(msg["Attributes"]) == 0:
+                    msg.pop("Attributes")
+                if len(msg["MessageAttributes"]) == 0:
+                    msg.pop("MessageAttributes")
+                msgs.append(msg)
+
+            return json.dumps({"Messages": msgs} if msgs else {})
+
         template = self.response_template(RECEIVE_MESSAGE_RESPONSE)
         return template.render(messages=messages, attributes=attributes)
 
+    @jsonify_error
     def list_dead_letter_source_queues(self) -> str:
         request_url = urlparse(self.uri)
         queue_name = self._get_queue_name()
 
-        source_queue_urls = self.sqs_backend.list_dead_letter_source_queues(queue_name)
+        queues = self.sqs_backend.list_dead_letter_source_queues(queue_name)
+
+        if self.is_json():
+            return json.dumps(
+                {"queueUrls": [queue.url(request_url) for queue in queues]}
+            )
 
         template = self.response_template(LIST_DEAD_LETTER_SOURCE_QUEUES_RESPONSE)
-        return template.render(queues=source_queue_urls, request_url=request_url)
+        return template.render(queues=queues, request_url=request_url)
 
+    @jsonify_error
     def add_permission(self) -> str:
         queue_name = self._get_queue_name()
-        actions = self._get_multi_param("ActionName")
-        account_ids = self._get_multi_param("AWSAccountId")
+        actions = (
+            self._get_param("Actions")
+            if self.is_json()
+            else self._get_multi_param("ActionName")
+        )
+        account_ids = (
+            self._get_param("AWSAccountIds")
+            if self.is_json()
+            else self._get_multi_param("AWSAccountId")
+        )
         label = self._get_param("Label")
 
         self.sqs_backend.add_permission(queue_name, actions, account_ids, label)
@@ -429,6 +687,7 @@ class SQSResponse(BaseResponse):
         template = self.response_template(ADD_PERMISSION_RESPONSE)
         return template.render()
 
+    @jsonify_error
     def remove_permission(self) -> str:
         queue_name = self._get_queue_name()
         label = self._get_param("Label")
@@ -438,21 +697,36 @@ class SQSResponse(BaseResponse):
         template = self.response_template(REMOVE_PERMISSION_RESPONSE)
         return template.render()
 
+    @jsonify_error
     def tag_queue(self) -> str:
         queue_name = self._get_queue_name()
-        tags = self._get_map_prefix("Tag", key_end=".Key", value_end=".Value")
+        if self.is_json():
+            tags = self._get_param("Tags")
+        else:
+            tags = self._get_map_prefix("Tag", key_end=".Key", value_end=".Value")
 
         self.sqs_backend.tag_queue(queue_name, tags)
 
+        if self.is_json():
+            return "{}"
+
         template = self.response_template(TAG_QUEUE_RESPONSE)
         return template.render()
 
+    @jsonify_error
     def untag_queue(self) -> str:
         queue_name = self._get_queue_name()
-        tag_keys = self._get_multi_param("TagKey")
+        tag_keys = (
+            self._get_param("TagKeys")
+            if self.is_json()
+            else self._get_multi_param("TagKey")
+        )
 
         self.sqs_backend.untag_queue(queue_name, tag_keys)
 
+        if self.is_json():
+            return "{}"
+
         template = self.response_template(UNTAG_QUEUE_RESPONSE)
         return template.render()
 
@@ -461,6 +735,9 @@ class SQSResponse(BaseResponse):
 
         queue = self.sqs_backend.list_queue_tags(queue_name)
 
+        if self.is_json():
+            return json.dumps({"Tags": queue.tags})
+
         template = self.response_template(LIST_QUEUE_TAGS_RESPONSE)
         return template.render(tags=queue.tags)
 
@@ -665,7 +942,7 @@ DELETE_MESSAGE_BATCH_RESPONSE = """<DeleteMessageBatchResponse>
             <Id>{{ error_dict['Id'] }}</Id>
             <Code>{{ error_dict['Code'] }}</Code>
             <Message>{{ error_dict['Message'] }}</Message>
-            <SenderFault>{{ error_dict['SenderFault'] }}</SenderFault>
+            <SenderFault>{{ 'true' if error_dict['SenderFault'] else 'false' }}</SenderFault>
         </BatchResultErrorEntry>
         {% endfor %}
     </DeleteMessageBatchResult>
@@ -756,16 +1033,6 @@ LIST_QUEUE_TAGS_RESPONSE = """<ListQueueTagsResponse>
    </ResponseMetadata>
 </ListQueueTagsResponse>"""
 
-ERROR_TOO_LONG_RESPONSE = """<ErrorResponse xmlns="http://queue.amazonaws.com/doc/2012-11-05/">
-    <Error>
-        <Type>Sender</Type>
-        <Code>InvalidParameterValue</Code>
-        <Message>One or more parameters are invalid. Reason: Message must be shorter than 262144 bytes.</Message>
-        <Detail/>
-    </Error>
-    <RequestId>6fde8d1e-52cd-4581-8cd9-c512f4c64223</RequestId>
-</ErrorResponse>"""
-
 ERROR_MAX_VISIBILITY_TIMEOUT_RESPONSE = (
     f"Invalid request, maximum visibility timeout is {MAXIMUM_VISIBILITY_TIMEOUT}"
 )
diff --git a/moto/sqs/utils.py b/moto/sqs/utils.py
index 33ba0ec9b..7f1d777d6 100644
--- a/moto/sqs/utils.py
+++ b/moto/sqs/utils.py
@@ -43,38 +43,51 @@ def parse_message_attributes(
             break
 
         data_type_key = base + f"{key}.{index}.{value_namespace}DataType"
-        data_type = querystring.get(data_type_key)
-        if not data_type:
-            raise MessageAttributesInvalid(
-                f"The message attribute '{name[0]}' must contain non-empty message attribute value."
-            )
+        data_type = querystring.get(data_type_key, [None])[0]
 
-        data_type_parts = data_type[0].split(".")
-        if data_type_parts[0] not in [
-            "String",
-            "Binary",
-            "Number",
-        ]:
-            raise MessageAttributesInvalid(
-                f"The message attribute '{name[0]}' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String."
-            )
+        data_type_parts = (data_type or "").split(".")[0]
 
         type_prefix = "String"
-        if data_type_parts[0] == "Binary":
+        if data_type_parts == "Binary":
             type_prefix = "Binary"
 
         value_key = base + f"{key}.{index}.{value_namespace}{type_prefix}Value"
-        value = querystring.get(value_key)
-        if not value:
-            raise MessageAttributesInvalid(
-                f"The message attribute '{name[0]}' must contain non-empty message attribute value for message attribute type '{data_type[0]}'."
-            )
+        value = querystring.get(value_key, [None])[0]
 
         message_attributes[name[0]] = {
-            "data_type": data_type[0],
-            type_prefix.lower() + "_value": value[0],
+            "data_type": data_type,
+            type_prefix.lower() + "_value": value,
         }
 
         index += 1
 
+    validate_message_attributes(message_attributes)
+
     return message_attributes
+
+
+def validate_message_attributes(message_attributes: Dict[str, Any]) -> None:
+    for name, value in (message_attributes or {}).items():
+        data_type = value["data_type"]
+
+        if not data_type:
+            raise MessageAttributesInvalid(
+                f"The message attribute '{name}' must contain non-empty message attribute value."
+            )
+
+        data_type_parts = data_type.split(".")[0]
+        if data_type_parts not in [
+            "String",
+            "Binary",
+            "Number",
+        ]:
+            raise MessageAttributesInvalid(
+                f"The message attribute '{name}' has an invalid message attribute type, the set of supported type prefixes is Binary, Number, and String."
+            )
+
+        possible_value_fields = ["string_value", "binary_value"]
+        for field in possible_value_fields:
+            if field in value and value[field] is None:
+                raise MessageAttributesInvalid(
+                    f"The message attribute '{name}' must contain non-empty message attribute value for message attribute type '{data_type}'."
+                )
diff --git a/moto/utilities/constants.py b/moto/utilities/constants.py
new file mode 100644
index 000000000..cea2a1aea
--- /dev/null
+++ b/moto/utilities/constants.py
@@ -0,0 +1,5 @@
+APPLICATION_AMZ_JSON_1_0 = "application/x-amz-json-1.0"
+APPLICATION_AMZ_JSON_1_1 = "application/x-amz-json-1.1"
+APPLICATION_JSON = "application/json"
+
+JSON_TYPES = [APPLICATION_JSON, APPLICATION_AMZ_JSON_1_0, APPLICATION_AMZ_JSON_1_1]
diff --git a/requirements-dev.txt b/requirements-dev.txt
index e1cdf2549..8b0aea9ac 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -9,6 +9,7 @@ click
 inflection
 lxml
 mypy
+typing-extensions<=4.5.0; python_version < '3.8'
 packaging
 build
 prompt_toolkit
diff --git a/setup.cfg b/setup.cfg
index 6d37533a8..d807a85df 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -52,7 +52,7 @@ all =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
 server =
@@ -66,7 +66,7 @@ server =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
     flask!=2.2.0,!=2.2.1
@@ -100,7 +100,7 @@ cloudformation =
     openapi-spec-validator>=0.2.8
     pyparsing>=3.0.7
     jsondiff>=1.1.2
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
     aws-xray-sdk!=0.96,>=0.93
     setuptools
 cloudfront =
@@ -123,10 +123,10 @@ dms =
 ds = sshpubkeys>=3.1.0
 dynamodb =
     docker>=3.0.0
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 dynamodbstreams =
     docker>=3.0.0
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 ebs = sshpubkeys>=3.1.0
 ec2 = sshpubkeys>=3.1.0
 ec2instanceconnect =
@@ -183,7 +183,7 @@ route53 =
 route53resolver = sshpubkeys>=3.1.0
 s3 =
     PyYAML>=5.1
-    py-partiql-parser==0.3.0
+    py-partiql-parser==0.3.1
 s3control =
 sagemaker =
 sdb =


Test patch:
diff --git a/.github/workflows/test_sqs.yml b/.github/workflows/test_sqs.yml
new file mode 100644
index 000000000..04e97bfa2
--- /dev/null
+++ b/.github/workflows/test_sqs.yml
@@ -0,0 +1,39 @@
+# Run separate test cases to verify SQS works with multiple botocore versions (/multiple response-types, QUERY and JSON)
+#
+name: "SQS Tests"
+
+on:
+  pull_request:
+    types: [ labeled ]
+
+jobs:
+  test:
+    if: ${{ github.event.label.name == 'service-sqs' }}
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        python-version: [ "3.11" ]
+        botocore-version: ["1.29.126", "1.29.127", "1.29.128"]
+
+    steps:
+    - name: Checkout repository
+      uses: actions/checkout@v3
+
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ matrix.python-version }}
+
+    - name: Update pip
+      run: |
+        python -m pip install --upgrade pip
+
+    - name: Install project dependencies
+      run: |
+        pip install -r requirements-dev.txt
+        pip install botocore==${{ matrix.botocore-version }}
+
+    - name: Run tests
+      run: |
+        pytest -sv tests/test_sqs
diff --git a/tests/test_apigateway/test_apigateway_importrestapi.py b/tests/test_apigateway/test_apigateway_importrestapi.py
index 0ecd35e98..64fb23726 100644
--- a/tests/test_apigateway/test_apigateway_importrestapi.py
+++ b/tests/test_apigateway/test_apigateway_importrestapi.py
@@ -1,9 +1,11 @@
 import boto3
 import os
 import pytest
+import sys
 
 from botocore.exceptions import ClientError
-from moto import mock_apigateway
+from moto import mock_apigateway, settings
+from unittest import SkipTest
 
 
 @mock_apigateway
@@ -48,6 +50,9 @@ def test_import_rest_api__nested_api():
 
 @mock_apigateway
 def test_import_rest_api__invalid_api_creates_nothing():
+    if sys.version_info < (3, 8) or settings.TEST_SERVER_MODE:
+        raise SkipTest("openapi-module throws an error in Py3.7")
+
     client = boto3.client("apigateway", region_name="us-west-2")
 
     path = os.path.dirname(os.path.abspath(__file__))
diff --git a/tests/test_apigateway/test_apigateway_putrestapi.py b/tests/test_apigateway/test_apigateway_putrestapi.py
index 6bd63cf8b..e5558051f 100644
--- a/tests/test_apigateway/test_apigateway_putrestapi.py
+++ b/tests/test_apigateway/test_apigateway_putrestapi.py
@@ -1,9 +1,11 @@
 import boto3
 import os
 import pytest
+import sys
 
 from botocore.exceptions import ClientError
-from moto import mock_apigateway
+from moto import mock_apigateway, settings
+from unittest import SkipTest
 
 
 @mock_apigateway
@@ -142,6 +144,9 @@ def test_put_rest_api__existing_methods_still_exist():
 
 @mock_apigateway
 def test_put_rest_api__fail_on_invalid_spec():
+    if sys.version_info < (3, 8) or settings.TEST_SERVER_MODE:
+        raise SkipTest("openapi-module throws an error in Py3.7")
+
     client = boto3.client("apigateway", region_name="us-east-2")
 
     response = client.create_rest_api(name="my_api", description="this is my api")
diff --git a/tests/test_appsync/test_appsync_schema.py b/tests/test_appsync/test_appsync_schema.py
index 0934687e8..dfc15e7c4 100644
--- a/tests/test_appsync/test_appsync_schema.py
+++ b/tests/test_appsync/test_appsync_schema.py
@@ -1,6 +1,8 @@
 import boto3
 import sure  # noqa # pylint: disable=unused-import
-
+import json
+from botocore.exceptions import ClientError
+import pytest
 from moto import mock_appsync
 
 schema = """type Mutation {
@@ -23,6 +25,33 @@ schema {
 
 }"""
 
+schema_with_directives = """type Mutation {
+    putPost(id: ID!, title: String!): Post
+}
+
+"My custom post type"
+type Post {
+    id: ID!
+    title: String!
+    createdAt: AWSDateTime!
+}
+
+type Query {
+    singlePost(id: ID!): Post
+}
+
+schema {
+    query: Query
+    mutation: Mutation
+    subscription: Subscription
+}
+
+type Subscription {
+    onPostCreated(id: ID!): Post @aws_subscribe(mutations: ["putPost"])
+}
+
+"""
+
 
 @mock_appsync
 def test_start_schema_creation():
@@ -86,3 +115,116 @@ def test_get_type_from_schema():
     query_type = client.get_type(apiId=api_id, typeName="Query", format="SDL")["type"]
     query_type.should.have.key("name").equals("Query")
     query_type.shouldnt.have.key("description")
+
+
+@mock_appsync
+def test_get_introspection_schema_raise_gql_schema_error_if_no_schema():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    with pytest.raises(ClientError) as exc:
+        client.get_introspection_schema(apiId=api_id, format="SDL")
+    err = exc.value.response["Error"]
+    err["Code"].should.equal("GraphQLSchemaException")
+    # AWS API appears to return InvalidSyntaxError if no schema exists
+    err["Message"].should.equal("InvalidSyntaxError")
+
+
+@mock_appsync
+def test_get_introspection_schema_sdl():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    resp = client.get_introspection_schema(apiId=api_id, format="SDL")
+    schema_sdl = resp["schema"].read().decode("utf-8")
+    schema_sdl.should.contain("putPost(")
+    schema_sdl.should.contain("singlePost(id: ID!): Post")
+
+
+@mock_appsync
+def test_get_introspection_schema_json():
+
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    resp = client.get_introspection_schema(apiId=api_id, format="JSON")
+    schema_json = json.loads(resp["schema"].read().decode("utf-8"))
+    schema_json.should.have.key("__schema")
+    schema_json["__schema"].should.have.key("queryType")
+    schema_json["__schema"].should.have.key("mutationType")
+    schema_json["__schema"].should.have.key("subscriptionType")
+    schema_json["__schema"].should.have.key("types")
+    schema_json["__schema"].should.have.key("directives")
+
+
+@mock_appsync
+def test_get_introspection_schema_bad_format():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(apiId=api_id, definition=schema.encode("utf-8"))
+
+    with pytest.raises(ClientError) as exc:
+        client.get_introspection_schema(apiId=api_id, format="NotAFormat")
+    err = exc.value.response["Error"]
+
+    err["Code"].should.equal("BadRequestException")
+    err["Message"].should.equal("Invalid format NotAFormat given")
+
+
+@mock_appsync
+def test_get_introspection_schema_include_directives_true():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(
+        apiId=api_id, definition=schema_with_directives.encode("utf-8")
+    )
+
+    resp = client.get_introspection_schema(
+        apiId=api_id, format="SDL", includeDirectives=True
+    )
+
+    schema_sdl = resp["schema"].read().decode("utf-8")
+
+    schema_sdl.should.contain("@aws_subscribe")
+
+
+@mock_appsync
+def test_get_introspection_schema_include_directives_false():
+    client = boto3.client("appsync", region_name="us-east-2")
+
+    api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[
+        "graphqlApi"
+    ]["apiId"]
+
+    client.start_schema_creation(
+        apiId=api_id, definition=schema_with_directives.encode("utf-8")
+    )
+
+    resp = client.get_introspection_schema(
+        apiId=api_id, format="SDL", includeDirectives=False
+    )
+
+    schema_sdl = resp["schema"].read().decode("utf-8")
+
+    schema_sdl.shouldnt.contain("@aws_subscribe")
diff --git a/tests/test_comprehend/test_comprehend.py b/tests/test_comprehend/test_comprehend.py
index d5f4aab8b..321a6eff1 100644
--- a/tests/test_comprehend/test_comprehend.py
+++ b/tests/test_comprehend/test_comprehend.py
@@ -4,6 +4,11 @@ import pytest
 import sure  # noqa # pylint: disable=unused-import
 from botocore.exceptions import ClientError
 from moto import mock_comprehend
+from moto.comprehend.models import (
+    CANNED_DETECT_RESPONSE,
+    CANNED_PHRASES_RESPONSE,
+    CANNED_SENTIMENT_RESPONSE,
+)
 
 # See our Development Tips on writing tests for hints on how to write good tests:
 # http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html
@@ -198,3 +203,174 @@ def test_delete_entity_recognizer():
     err["Message"].should.equal(
         "RESOURCE_NOT_FOUND: Could not find specified resource."
     )
+
+
+@mock_comprehend
+def test_detect_pii_entities():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_pii_entities(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    assert "Entities" in result
+    assert result["Entities"] == CANNED_DETECT_RESPONSE
+
+
+@mock_comprehend
+def test_detect_pii_entities_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "es"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_pii_entities(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        f"Member must satisfy enum value set: [en]"
+    )
+
+
+@mock_comprehend
+def test_detect_pii_entities_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 100001
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_pii_entities(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes "
+        f"while in this request the text size is {size} bytes"
+    )
+
+
+@mock_comprehend
+def test_detect_key_phrases():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_key_phrases(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    assert "KeyPhrases" in result
+    assert result["KeyPhrases"] == CANNED_PHRASES_RESPONSE
+
+
+@mock_comprehend
+def test_detect_key_phrases_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "blah"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_key_phrases(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        f"Member must satisfy enum value set: [ar, hi, ko, zh-TW, ja, zh, de, pt, en, it, fr, es]"
+    )
+
+
+@mock_comprehend
+def test_detect_detect_key_phrases_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 100002
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_key_phrases(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes "
+        f"while in this request the text size is {size} bytes"
+    )
+
+
+@mock_comprehend
+def test_detect_sentiment():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+
+    # Execute
+    result = client.detect_sentiment(Text=sample_text, LanguageCode="en")
+
+    # Verify
+    del result["ResponseMetadata"]
+    assert result == CANNED_SENTIMENT_RESPONSE
+
+
+@mock_comprehend
+def test_detect_sentiment_invalid_languages():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    sample_text = "Doesn't matter what we send, we will get a canned response"
+    language = "blah"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_sentiment(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "ValidationException"
+    assert (
+        err["Message"]
+        == f"Value '{language}' at 'languageCode'failed to satisfy constraint: "
+        "Member must satisfy enum value set: [ar, hi, ko, zh-TW, ja, zh, de, pt, en, it, fr, es]"
+    )
+
+
+@mock_comprehend
+def test_detect_sentiment_text_too_large():
+    # Setup
+    client = boto3.client("comprehend", region_name="eu-west-1")
+    size = 5001
+    sample_text = "x" * size
+    language = "en"
+
+    # Execute
+    with pytest.raises(ClientError) as exc:
+        client.detect_sentiment(Text=sample_text, LanguageCode=language)
+
+    # Verify
+    err = exc.value.response["Error"]
+    assert err["Code"] == "TextSizeLimitExceededException"
+    assert (
+        err["Message"]
+        == "Input text size exceeds limit. Max length of request text allowed is 100000 bytes while "
+        f"in this request the text size is {size} bytes"
+    )
diff --git a/tests/test_ec2/test_key_pairs.py b/tests/test_ec2/test_key_pairs.py
index 5826dc500..c0af777dc 100644
--- a/tests/test_ec2/test_key_pairs.py
+++ b/tests/test_ec2/test_key_pairs.py
@@ -1,4 +1,5 @@
 import pytest
+from datetime import datetime
 
 import sure  # noqa # pylint: disable=unused-import
 import boto3
@@ -101,6 +102,7 @@ def test_key_pairs_create_boto3():
     kps[0].should.have.key("KeyPairId")
     kps[0].should.have.key("KeyName").equal(key_name)
     kps[0].should.have.key("KeyFingerprint")
+    kps[0].should.have.key("CreateTime").should.be.a(datetime)
 
 
 @mock_ec2
diff --git a/tests/test_s3/test_s3_select.py b/tests/test_s3/test_s3_select.py
index aea6cc9eb..8b9d77a3f 100644
--- a/tests/test_s3/test_s3_select.py
+++ b/tests/test_s3/test_s3_select.py
@@ -6,7 +6,7 @@ from unittest import TestCase
 from uuid import uuid4
 
 
-SIMPLE_JSON = {"a1": "b1", "a2": "b2"}
+SIMPLE_JSON = {"a1": "b1", "a2": "b2", "a3": None}
 SIMPLE_JSON2 = {"a1": "b2", "a3": "b3"}
 SIMPLE_LIST = [SIMPLE_JSON, SIMPLE_JSON2]
 SIMPLE_CSV = """a,b,c
@@ -47,7 +47,14 @@ class TestS3Select(TestCase):
             OutputSerialization={"JSON": {"RecordDelimiter": ","}},
         )
         result = list(x["Payload"])
-        result.should.contain({"Records": {"Payload": b'{"a1":"b1","a2":"b2"},'}})
+        result.should.contain(
+            {"Records": {"Payload": b'{"a1":"b1","a2":"b2","a3":null},'}}
+        )
+
+        # Verify result is valid JSON
+        json.loads(result[0]["Records"]["Payload"][0:-1].decode("utf-8"))
+
+        # Verify result contains metadata
         result.should.contain(
             {
                 "Stats": {