Repository: getmoto/moto
Pull request #6337 (2023-05-22 11:34:46)

Appsync: Implement get_introspection_schema
This PR implements AppSync get_introspection_schema.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) Report
> Merging [#6337](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (7250dfe) into [master](https://app.codecov.io/gh/getmoto/moto/commit/a57284a942a9d165465c67dc1b65078a881deb7f?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) (a57284a) will **decrease** coverage by `0.01%`.
> The diff coverage is `86.04%`.

```diff
@@            Coverage Diff             @@
##           master    #6337      +/-   ##
==========================================
- Coverage   96.27%   96.26%   -0.01%     
==========================================
  Files         793      793              
  Lines       77223    77276      +53     
==========================================
+ Hits        74343    74387      +44     
- Misses       2880     2889       +9     
```

| Flag | Coverage Δ | |
|---|---|---|
| servertests | `36.91% <30.23%> (-0.01%)` | :arrow_down: |
| unittests | `96.20% <86.04%> (-0.01%)` | :arrow_down: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None) | Coverage Δ | |
|---|---|---|
| [moto/appsync/urls.py](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bW90by9hcHBzeW5jL3VybHMucHk=) | `100.00% <ø> (ø)` | |
| [moto/appsync/exceptions.py](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bW90by9hcHBzeW5jL2V4Y2VwdGlvbnMucHk=) | `82.35% <62.50%> (-17.65%)` | :arrow_down: |
| [moto/appsync/models.py](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bW90by9hcHBzeW5jL21vZGVscy5weQ==) | `95.93% <85.71%> (-1.44%)` | :arrow_down: |
| [moto/\_\_init\_\_.py](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bW90by9fX2luaXRfXy5weQ==) | `98.67% <100.00%> (ø)` | |
| [moto/appsync/responses.py](https://app.codecov.io/gh/getmoto/moto/pull/6337?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None#diff-bW90by9hcHBzeW5jL3Jlc3BvbnNlcy5weQ==) | `100.00% <100.00%> (ø)` | |

... and [5 files with indirect coverage changes](https://app.codecov.io/gh/getmoto/moto/pull/6337/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=None)



Patch:
diff --git a/.github/workflows/payment_notification.yml b/.github/workflows/payment_notification.yml
index b892611fa..d41127117 100644
--- a/.github/workflows/payment_notification.yml
+++ b/.github/workflows/payment_notification.yml
@@ -17,4 +17,14 @@ jobs:
       with:
         issue-number: ${{ context.issue.number }}
         body: |
-          Test message
+          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/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/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/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/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/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_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": {