From 18b6bdf20a6b71009e7e139a287ee91461b3011d Mon Sep 17 00:00:00 2001 From: Dave Pretty Date: Fri, 26 May 2023 02:40:52 +1000 Subject: [PATCH] Appsync: Implement get_introspection_schema (#6337) --- docs/docs/services/appsync.rst | 2 +- moto/appsync/exceptions.py | 13 ++ moto/appsync/models.py | 60 ++++++++- moto/appsync/responses.py | 21 ++++ moto/appsync/urls.py | 1 + tests/test_appsync/test_appsync_schema.py | 144 +++++++++++++++++++++- 6 files changed, 238 insertions(+), 3 deletions(-) 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/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[^/]+)/apikeys$": response.api_key, "{0}/v1/apis/(?P[^/]+)/apikeys/(?P[^/]+)$": response.api_key_individual, "{0}/v1/apis/(?P[^/]+)/schemacreation$": response.schemacreation, + "{0}/v1/apis/(?P[^/]+)/schema$": response.schema, "{0}/v1/tags/(?P.+)$": response.tags, "{0}/v1/tags/(?P.+)/(?P.+)$": response.tags, "{0}/v1/apis/(?P[^/]+)/types/(?P.+)$": response.types, 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")