From f3313be99183b4b4f602987eb49f64805d1facd4 Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Fri, 14 Jan 2022 20:12:26 -0100 Subject: [PATCH] AppSync - initial implementation (#4761) --- IMPLEMENTATION_COVERAGE.md | 56 +++- docs/docs/services/appsync.rst | 87 ++++++ moto/__init__.py | 1 + moto/appsync/__init__.py | 5 + moto/appsync/exceptions.py | 16 ++ moto/appsync/models.py | 309 +++++++++++++++++++++ moto/appsync/responses.py | 250 +++++++++++++++++ moto/appsync/urls.py | 20 ++ moto/backend_index.py | 1 + moto/utilities/tagging_service.py | 2 + setup.py | 3 + tests/terraform-tests.success.txt | 2 + tests/test_appsync/__init__.py | 0 tests/test_appsync/test_appsync.py | 164 +++++++++++ tests/test_appsync/test_appsync_apikeys.py | 112 ++++++++ tests/test_appsync/test_appsync_schema.py | 88 ++++++ tests/test_appsync/test_appsync_tags.py | 86 ++++++ tests/test_appsync/test_server.py | 15 + 18 files changed, 1216 insertions(+), 1 deletion(-) create mode 100644 docs/docs/services/appsync.rst create mode 100644 moto/appsync/__init__.py create mode 100644 moto/appsync/exceptions.py create mode 100644 moto/appsync/models.py create mode 100644 moto/appsync/responses.py create mode 100644 moto/appsync/urls.py create mode 100644 tests/test_appsync/__init__.py create mode 100644 tests/test_appsync/test_appsync.py create mode 100644 tests/test_appsync/test_appsync_apikeys.py create mode 100644 tests/test_appsync/test_appsync_schema.py create mode 100644 tests/test_appsync/test_appsync_tags.py create mode 100644 tests/test_appsync/test_server.py diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 7dbe791e1..7004b23c9 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -162,6 +162,61 @@ - [X] register_scalable_target +## appsync +
+30% implemented + +- [ ] associate_api +- [ ] create_api_cache +- [X] create_api_key +- [ ] create_data_source +- [ ] create_domain_name +- [ ] create_function +- [X] create_graphql_api +- [ ] create_resolver +- [ ] create_type +- [ ] delete_api_cache +- [X] delete_api_key +- [ ] delete_data_source +- [ ] delete_domain_name +- [ ] delete_function +- [X] delete_graphql_api +- [ ] delete_resolver +- [ ] delete_type +- [ ] disassociate_api +- [ ] flush_api_cache +- [ ] get_api_association +- [ ] get_api_cache +- [ ] get_data_source +- [ ] get_domain_name +- [ ] get_function +- [X] get_graphql_api +- [ ] get_introspection_schema +- [ ] get_resolver +- [X] get_schema_creation_status +- [X] get_type +- [X] list_api_keys +- [ ] list_data_sources +- [ ] list_domain_names +- [ ] list_functions +- [X] list_graphql_apis +- [ ] list_resolvers +- [ ] list_resolvers_by_function +- [X] list_tags_for_resource +- [ ] list_types +- [X] start_schema_creation +- [X] tag_resource +- [X] untag_resource +- [ ] update_api_cache +- [X] update_api_key +- [ ] update_data_source +- [ ] update_domain_name +- [ ] update_function +- [X] update_graphql_api +- [ ] update_resolver +- [ ] update_type +
+ ## athena
20% implemented @@ -5132,7 +5187,6 @@ - appmesh - apprunner - appstream -- appsync - auditmanager - autoscaling-plans - backup diff --git a/docs/docs/services/appsync.rst b/docs/docs/services/appsync.rst new file mode 100644 index 000000000..207fbeb34 --- /dev/null +++ b/docs/docs/services/appsync.rst @@ -0,0 +1,87 @@ +.. _implementedservice_appsync: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +======= +appsync +======= + +.. autoclass:: moto.appsync.models.AppSyncBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_appsync + def test_appsync_behaviour: + boto3.client("appsync") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] associate_api +- [ ] create_api_cache +- [X] create_api_key +- [ ] create_data_source +- [ ] create_domain_name +- [ ] create_function +- [X] create_graphql_api +- [ ] create_resolver +- [ ] create_type +- [ ] delete_api_cache +- [X] delete_api_key +- [ ] delete_data_source +- [ ] delete_domain_name +- [ ] delete_function +- [X] delete_graphql_api +- [ ] delete_resolver +- [ ] delete_type +- [ ] disassociate_api +- [ ] flush_api_cache +- [ ] get_api_association +- [ ] get_api_cache +- [ ] get_data_source +- [ ] get_domain_name +- [ ] get_function +- [X] get_graphql_api +- [ ] get_introspection_schema +- [ ] get_resolver +- [X] get_schema_creation_status +- [X] get_type +- [X] list_api_keys + + Pagination or the maxResults-parameter have not yet been implemented. + + +- [ ] list_data_sources +- [ ] list_domain_names +- [ ] list_functions +- [X] list_graphql_apis + + Pagination or the maxResults-parameter have not yet been implemented. + + +- [ ] list_resolvers +- [ ] list_resolvers_by_function +- [X] list_tags_for_resource +- [ ] list_types +- [X] start_schema_creation +- [X] tag_resource +- [X] untag_resource +- [ ] update_api_cache +- [X] update_api_key +- [ ] update_data_source +- [ ] update_domain_name +- [ ] update_function +- [X] update_graphql_api +- [ ] update_resolver +- [ ] update_type + diff --git a/moto/__init__.py b/moto/__init__.py index ba0dd6cca..845d684e0 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -27,6 +27,7 @@ def lazy_load( mock_acm = lazy_load(".acm", "mock_acm") mock_apigateway = lazy_load(".apigateway", "mock_apigateway") mock_apigateway_deprecated = lazy_load(".apigateway", "mock_apigateway_deprecated") +mock_appsync = lazy_load(".appsync", "mock_appsync", boto3_name="appsync") mock_athena = lazy_load(".athena", "mock_athena") mock_applicationautoscaling = lazy_load( ".applicationautoscaling", "mock_applicationautoscaling" diff --git a/moto/appsync/__init__.py b/moto/appsync/__init__.py new file mode 100644 index 000000000..9fa424771 --- /dev/null +++ b/moto/appsync/__init__.py @@ -0,0 +1,5 @@ +"""appsync module initialization; sets value for base decorator.""" +from .models import appsync_backends +from ..core.models import base_decorator + +mock_appsync = base_decorator(appsync_backends) diff --git a/moto/appsync/exceptions.py b/moto/appsync/exceptions.py new file mode 100644 index 000000000..c29d54ccf --- /dev/null +++ b/moto/appsync/exceptions.py @@ -0,0 +1,16 @@ +import json +from moto.core.exceptions import JsonRESTError + + +class AppSyncExceptions(JsonRESTError): + pass + + +class GraphqlAPINotFound(AppSyncExceptions): + code = 404 + + def __init__(self, api_id): + super().__init__( + "NotFoundException", f"GraphQL API {api_id} not found.", + ) + self.description = json.dumps({"message": self.message}) diff --git a/moto/appsync/models.py b/moto/appsync/models.py new file mode 100644 index 000000000..2e9379f09 --- /dev/null +++ b/moto/appsync/models.py @@ -0,0 +1,309 @@ +import base64 +from datetime import timedelta, datetime, timezone +from moto.core import ACCOUNT_ID, BaseBackend, BaseModel +from moto.core.utils import BackendDict, unix_time +from moto.utilities.tagging_service import TaggingService + +from uuid import uuid4 + +from .exceptions import GraphqlAPINotFound + + +class GraphqlSchema(BaseModel): + def __init__(self, definition): + self.definition = definition + # [graphql.language.ast.ObjectTypeDefinitionNode, ..] + self.types = [] + + self.status = "PROCESSING" + self.parse_error = None + self._parse_graphql_definition() + + def get_type(self, name): + for graphql_type in self.types: + if graphql_type.name.value == name: + return { + "name": name, + "description": graphql_type.description.value + if graphql_type.description + else None, + "arn": f"arn:aws:appsync:graphql_type/{name}", + "definition": "NotYetImplemented", + } + + def get_status(self): + return self.status, self.parse_error + + def _parse_graphql_definition(self): + try: + from graphql import parse + from graphql.language.ast import ObjectTypeDefinitionNode + from graphql.error.graphql_error import GraphQLError + + res = parse(self.definition) + for definition in res.definitions: + if isinstance(definition, ObjectTypeDefinitionNode): + self.types.append(definition) + self.status = "SUCCESS" + except GraphQLError as e: + self.status = "FAILED" + self.parse_error = str(e) + + +class GraphqlAPI(BaseModel): + def __init__( + self, + region, + name, + authentication_type, + additional_authentication_providers, + log_config, + xray_enabled, + user_pool_config, + open_id_connect_config, + lambda_authorizer_config, + ): + self.region = region + self.name = name + self.api_id = str(uuid4()) + self.authentication_type = authentication_type + self.additional_authentication_providers = additional_authentication_providers + self.lambda_authorizer_config = lambda_authorizer_config + self.log_config = log_config + self.open_id_connect_config = open_id_connect_config + self.user_pool_config = user_pool_config + self.xray_enabled = xray_enabled + + self.arn = f"arn:aws:appsync:{self.region}:{ACCOUNT_ID}:apis/{self.api_id}" + self.graphql_schema = None + + self.api_keys = dict() + + def update( + self, + name, + additional_authentication_providers, + authentication_type, + lambda_authorizer_config, + log_config, + open_id_connect_config, + user_pool_config, + xray_enabled, + ): + if name: + self.name = name + if additional_authentication_providers: + self.additional_authentication_providers = ( + additional_authentication_providers + ) + if authentication_type: + self.authentication_type = authentication_type + if lambda_authorizer_config: + self.lambda_authorizer_config = lambda_authorizer_config + if log_config: + self.log_config = log_config + if open_id_connect_config: + self.open_id_connect_config = open_id_connect_config + if user_pool_config: + self.user_pool_config = user_pool_config + if xray_enabled is not None: + self.xray_enabled = xray_enabled + + def create_api_key(self, description, expires): + api_key = GraphqlAPIKey(description, expires) + self.api_keys[api_key.key_id] = api_key + return api_key + + def list_api_keys(self): + return self.api_keys.values() + + def delete_api_key(self, api_key_id): + self.api_keys.pop(api_key_id) + + def update_api_key(self, api_key_id, description, expires): + api_key = self.api_keys[api_key_id] + api_key.update(description, expires) + return api_key + + def start_schema_creation(self, definition): + graphql_definition = base64.b64decode(definition).decode("utf-8") + + self.graphql_schema = GraphqlSchema(graphql_definition) + + def get_schema_status(self): + return self.graphql_schema.get_status() + + def get_type(self, type_name, type_format): + graphql_type = self.graphql_schema.get_type(type_name) + graphql_type["format"] = type_format + return graphql_type + + def to_json(self): + return { + "name": self.name, + "apiId": self.api_id, + "authenticationType": self.authentication_type, + "arn": self.arn, + "uris": {"GRAPHQL": "http://graphql.uri"}, + "additionalAuthenticationProviders": self.additional_authentication_providers, + "lambdaAuthorizerConfig": self.lambda_authorizer_config, + "logConfig": self.log_config, + "openIDConnectConfig": self.open_id_connect_config, + "userPoolConfig": self.user_pool_config, + "xrayEnabled": self.xray_enabled, + } + + +class GraphqlAPIKey(BaseModel): + def __init__(self, description, expires): + self.key_id = str(uuid4())[0:6] + self.description = description + self.expires = expires + if not self.expires: + default_expiry = datetime.now(timezone.utc) + default_expiry = default_expiry.replace( + minute=0, second=0, microsecond=0, tzinfo=None + ) + default_expiry = default_expiry + timedelta(days=7) + self.expires = unix_time(default_expiry) + + def update(self, description, expires): + if description: + self.description = description + if expires: + self.expires = expires + + def to_json(self): + return { + "id": self.key_id, + "description": self.description, + "expires": self.expires, + "deletes": self.expires, + } + + +class AppSyncBackend(BaseBackend): + """Implementation of AppSync APIs.""" + + def __init__(self, region_name=None): + self.region_name = region_name + self.graphql_apis = dict() + self.tagger = TaggingService() + + def reset(self): + """Re-initialize all attributes for this instance.""" + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + def create_graphql_api( + self, + name, + log_config, + authentication_type, + user_pool_config, + open_id_connect_config, + additional_authentication_providers, + xray_enabled, + lambda_authorizer_config, + tags, + ): + graphql_api = GraphqlAPI( + region=self.region_name, + name=name, + authentication_type=authentication_type, + additional_authentication_providers=additional_authentication_providers, + log_config=log_config, + xray_enabled=xray_enabled, + user_pool_config=user_pool_config, + open_id_connect_config=open_id_connect_config, + lambda_authorizer_config=lambda_authorizer_config, + ) + self.graphql_apis[graphql_api.api_id] = graphql_api + self.tagger.tag_resource( + graphql_api.arn, TaggingService.convert_dict_to_tags_input(tags) + ) + return graphql_api + + def update_graphql_api( + self, + api_id, + name, + log_config, + authentication_type, + user_pool_config, + open_id_connect_config, + additional_authentication_providers, + xray_enabled, + lambda_authorizer_config, + ): + graphql_api = self.graphql_apis[api_id] + graphql_api.update( + name, + additional_authentication_providers, + authentication_type, + lambda_authorizer_config, + log_config, + open_id_connect_config, + user_pool_config, + xray_enabled, + ) + return graphql_api + + def get_graphql_api(self, api_id): + if api_id not in self.graphql_apis: + raise GraphqlAPINotFound(api_id) + return self.graphql_apis[api_id] + + def delete_graphql_api(self, api_id): + self.graphql_apis.pop(api_id) + + def list_graphql_apis(self): + """ + Pagination or the maxResults-parameter have not yet been implemented. + """ + return self.graphql_apis.values() + + def create_api_key(self, api_id, description, expires): + return self.graphql_apis[api_id].create_api_key(description, expires) + + def delete_api_key(self, api_id, api_key_id): + self.graphql_apis[api_id].delete_api_key(api_key_id) + + def list_api_keys(self, api_id): + """ + Pagination or the maxResults-parameter have not yet been implemented. + """ + if api_id in self.graphql_apis: + return self.graphql_apis[api_id].list_api_keys() + else: + return [] + + def update_api_key(self, api_id, api_key_id, description, expires): + return self.graphql_apis[api_id].update_api_key( + api_key_id, description, expires + ) + + def start_schema_creation(self, api_id, definition): + self.graphql_apis[api_id].start_schema_creation(definition) + return "PROCESSING" + + def get_schema_creation_status(self, api_id): + return self.graphql_apis[api_id].get_schema_status() + + def tag_resource(self, resource_arn, tags): + self.tagger.tag_resource( + resource_arn, TaggingService.convert_dict_to_tags_input(tags) + ) + + def untag_resource(self, resource_arn, tag_keys): + self.tagger.untag_resource_using_names(resource_arn, tag_keys) + + def list_tags_for_resource(self, resource_arn): + return self.tagger.get_tag_dict_for_resource(resource_arn) + + def get_type(self, api_id, type_name, type_format): + return self.graphql_apis[api_id].get_type(type_name, type_format) + + +appsync_backends = BackendDict(AppSyncBackend, "appsync") diff --git a/moto/appsync/responses.py b/moto/appsync/responses.py new file mode 100644 index 000000000..e18a53726 --- /dev/null +++ b/moto/appsync/responses.py @@ -0,0 +1,250 @@ +"""Handles incoming appsync requests, invokes methods, returns responses.""" +import json + +from functools import wraps +from moto.core.responses import BaseResponse +from urllib.parse import unquote +from .exceptions import AppSyncExceptions +from .models import appsync_backends + + +def error_handler(f): + @wraps(f) + def _wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except AppSyncExceptions as e: + return e.code, e.get_headers(), e.get_body() + + return _wrapper + + +class AppSyncResponse(BaseResponse): + """Handler for AppSync requests and responses.""" + + @property + def appsync_backend(self): + """Return backend instance specific for this region.""" + return appsync_backends[self.region] + + def graph_ql(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "POST": + return self.create_graphql_api() + if request.method == "GET": + return self.list_graphql_apis() + + @error_handler + def graph_ql_individual(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "GET": + return self.get_graphql_api() + if request.method == "DELETE": + return self.delete_graphql_api() + if request.method == "POST": + return self.update_graphql_api() + + def api_key(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "POST": + return self.create_api_key() + if request.method == "GET": + return self.list_api_keys() + + def schemacreation(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "POST": + return self.start_schema_creation() + if request.method == "GET": + return self.get_schema_creation_status() + + def api_key_individual(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "DELETE": + return self.delete_api_key() + if request.method == "POST": + return self.update_api_key() + + def tags(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "POST": + return self.tag_resource() + if request.method == "DELETE": + return self.untag_resource() + if request.method == "GET": + return self.list_tags_for_resource() + + def types(self, request, full_url, headers): + self.setup_class(request, full_url, headers) + if request.method == "GET": + return self.get_type() + + def create_graphql_api(self): + params = json.loads(self.body) + name = params.get("name") + log_config = params.get("logConfig") + authentication_type = params.get("authenticationType") + user_pool_config = params.get("userPoolConfig") + open_id_connect_config = params.get("openIDConnectConfig") + tags = params.get("tags") + additional_authentication_providers = params.get( + "additionalAuthenticationProviders" + ) + xray_enabled = params.get("xrayEnabled", False) + lambda_authorizer_config = params.get("lambdaAuthorizerConfig") + graphql_api = self.appsync_backend.create_graphql_api( + name=name, + log_config=log_config, + authentication_type=authentication_type, + user_pool_config=user_pool_config, + open_id_connect_config=open_id_connect_config, + additional_authentication_providers=additional_authentication_providers, + xray_enabled=xray_enabled, + lambda_authorizer_config=lambda_authorizer_config, + tags=tags, + ) + response = graphql_api.to_json() + response["tags"] = self.appsync_backend.list_tags_for_resource(graphql_api.arn) + return 200, {}, json.dumps(dict(graphqlApi=response)) + + def get_graphql_api(self): + api_id = self.path.split("/")[-1] + + graphql_api = self.appsync_backend.get_graphql_api(api_id=api_id) + response = graphql_api.to_json() + response["tags"] = self.appsync_backend.list_tags_for_resource(graphql_api.arn) + return 200, {}, json.dumps(dict(graphqlApi=response)) + + def delete_graphql_api(self): + api_id = self.path.split("/")[-1] + self.appsync_backend.delete_graphql_api(api_id=api_id) + return 200, {}, json.dumps(dict()) + + def update_graphql_api(self): + api_id = self.path.split("/")[-1] + + params = json.loads(self.body) + name = params.get("name") + log_config = params.get("logConfig") + authentication_type = params.get("authenticationType") + user_pool_config = params.get("userPoolConfig") + print(user_pool_config) + open_id_connect_config = params.get("openIDConnectConfig") + additional_authentication_providers = params.get( + "additionalAuthenticationProviders" + ) + xray_enabled = params.get("xrayEnabled", False) + lambda_authorizer_config = params.get("lambdaAuthorizerConfig") + + api = self.appsync_backend.update_graphql_api( + api_id=api_id, + name=name, + log_config=log_config, + authentication_type=authentication_type, + user_pool_config=user_pool_config, + open_id_connect_config=open_id_connect_config, + additional_authentication_providers=additional_authentication_providers, + xray_enabled=xray_enabled, + lambda_authorizer_config=lambda_authorizer_config, + ) + return 200, {}, json.dumps(dict(graphqlApi=api.to_json())) + + def list_graphql_apis(self): + graphql_apis = self.appsync_backend.list_graphql_apis() + return ( + 200, + {}, + json.dumps(dict(graphqlApis=[api.to_json() for api in graphql_apis])), + ) + + def create_api_key(self): + params = json.loads(self.body) + # /v1/apis/[api_id]/apikeys + api_id = self.path.split("/")[-2] + description = params.get("description") + expires = params.get("expires") + api_key = self.appsync_backend.create_api_key( + api_id=api_id, description=description, expires=expires, + ) + print(api_key.to_json()) + return 200, {}, json.dumps(dict(apiKey=api_key.to_json())) + + def delete_api_key(self): + api_id = self.path.split("/")[-3] + api_key_id = self.path.split("/")[-1] + self.appsync_backend.delete_api_key( + api_id=api_id, api_key_id=api_key_id, + ) + return 200, {}, json.dumps(dict()) + + def list_api_keys(self): + # /v1/apis/[api_id]/apikeys + api_id = self.path.split("/")[-2] + api_keys = self.appsync_backend.list_api_keys(api_id=api_id) + return 200, {}, json.dumps(dict(apiKeys=[key.to_json() for key in api_keys])) + + def update_api_key(self): + api_id = self.path.split("/")[-3] + api_key_id = self.path.split("/")[-1] + params = json.loads(self.body) + description = params.get("description") + expires = params.get("expires") + api_key = self.appsync_backend.update_api_key( + api_id=api_id, + api_key_id=api_key_id, + description=description, + expires=expires, + ) + return 200, {}, json.dumps(dict(apiKey=api_key.to_json())) + + def start_schema_creation(self): + params = json.loads(self.body) + api_id = self.path.split("/")[-2] + definition = params.get("definition") + status = self.appsync_backend.start_schema_creation( + api_id=api_id, definition=definition, + ) + return 200, {}, json.dumps({"status": status}) + + def get_schema_creation_status(self): + api_id = self.path.split("/")[-2] + status, details = self.appsync_backend.get_schema_creation_status( + api_id=api_id, + ) + return 200, {}, json.dumps(dict(status=status, details=details)) + + def tag_resource(self): + resource_arn = self._extract_arn_from_path() + params = json.loads(self.body) + tags = params.get("tags") + self.appsync_backend.tag_resource( + resource_arn=resource_arn, tags=tags, + ) + return 200, {}, json.dumps(dict()) + + def untag_resource(self): + resource_arn = self._extract_arn_from_path() + tag_keys = self.querystring.get("tagKeys", []) + self.appsync_backend.untag_resource( + resource_arn=resource_arn, tag_keys=tag_keys, + ) + return 200, {}, json.dumps(dict()) + + def list_tags_for_resource(self): + resource_arn = self._extract_arn_from_path() + tags = self.appsync_backend.list_tags_for_resource(resource_arn=resource_arn,) + return 200, {}, json.dumps(dict(tags=tags)) + + def _extract_arn_from_path(self): + # /v1/tags/arn_that_may_contain_a_slash + path = unquote(self.path) + return "/".join(path.split("/")[3:]) + + def get_type(self): + api_id = unquote(self.path.split("/")[-3]) + type_name = self.path.split("/")[-1] + type_format = self.querystring.get("format")[0] + graphql_type = self.appsync_backend.get_type( + api_id=api_id, type_name=type_name, type_format=type_format, + ) + return 200, {}, json.dumps(dict(type=graphql_type)) diff --git a/moto/appsync/urls.py b/moto/appsync/urls.py new file mode 100644 index 000000000..e165c7944 --- /dev/null +++ b/moto/appsync/urls.py @@ -0,0 +1,20 @@ +"""appsync base URL and path.""" +from .responses import AppSyncResponse + +url_bases = [ + r"https?://appsync\.(.+)\.amazonaws\.com", +] + + +response = AppSyncResponse() + + +url_paths = { + "{0}/v1/apis$": response.graph_ql, + "{0}/v1/apis/(?P[^/]+)$": response.graph_ql_individual, + "{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/tags/(?P.+)$": response.tags, + "{0}/v1/apis/(?P[^/]+)/types/(?P.+)$": response.types, +} diff --git a/moto/backend_index.py b/moto/backend_index.py index 82749d233..9319f2975 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -8,6 +8,7 @@ backend_url_patterns = [ "applicationautoscaling", re.compile("https?://application-autoscaling\\.(.+)\\.amazonaws.com"), ), + ("appsync", re.compile("https?://appsync\\.(.+)\\.amazonaws\\.com")), ("athena", re.compile("https?://athena\\.(.+)\\.amazonaws\\.com")), ("autoscaling", re.compile("https?://autoscaling\\.(.+)\\.amazonaws\\.com")), ("batch", re.compile("https?://batch\\.(.+)\\.amazonaws.com")), diff --git a/moto/utilities/tagging_service.py b/moto/utilities/tagging_service.py index 772b9922c..d861137c6 100644 --- a/moto/utilities/tagging_service.py +++ b/moto/utilities/tagging_service.py @@ -168,4 +168,6 @@ class TaggingService: @staticmethod def convert_dict_to_tags_input(tags): """ Given a dictionary, return generic boto params for tags """ + if not tags: + return [] return [{"Key": k, "Value": v} for (k, v) in tags.items()] diff --git a/setup.py b/setup.py index 38219f5c1..80da71b54 100755 --- a/setup.py +++ b/setup.py @@ -49,6 +49,7 @@ _dep_python_jose_ecdsa_pin = ( ) _dep_dataclasses = "dataclasses; python_version < '3.7'" _dep_docker = "docker>=2.5.1" +_dep_graphql = "graphql-core" _dep_jsondiff = "jsondiff>=1.1.2" _dep_aws_xray_sdk = "aws-xray-sdk!=0.96,>=0.93" _dep_idna = "idna<4,>=2.5" @@ -61,6 +62,7 @@ all_extra_deps = [ _dep_python_jose, _dep_python_jose_ecdsa_pin, _dep_docker, + _dep_graphql, _dep_jsondiff, _dep_aws_xray_sdk, _dep_idna, @@ -80,6 +82,7 @@ for service_name in [ extras_per_service.update( { "apigateway": [_dep_python_jose, _dep_python_jose_ecdsa_pin], + "appsync": [_dep_graphql], "awslambda": [_dep_docker], "batch": [_dep_docker], "cloudformation": [_dep_docker, _dep_PyYAML, _dep_cfn_lint], diff --git a/tests/terraform-tests.success.txt b/tests/terraform-tests.success.txt index bf3b3b636..10a0a8478 100644 --- a/tests/terraform-tests.success.txt +++ b/tests/terraform-tests.success.txt @@ -1,5 +1,7 @@ TestAccAWSAccessKey TestAccAWSAvailabilityZones +TestAccAWSAppsyncApiKey +TestAccAWSAppsyncGraphqlApi TestAccAWSBillingServiceAccount TestAccAWSCallerIdentity TestAccAWSCloudTrailServiceAccount diff --git a/tests/test_appsync/__init__.py b/tests/test_appsync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_appsync/test_appsync.py b/tests/test_appsync/test_appsync.py new file mode 100644 index 000000000..14c275643 --- /dev/null +++ b/tests/test_appsync/test_appsync.py @@ -0,0 +1,164 @@ +import boto3 +import pytest +import sure # noqa # pylint: disable=unused-import + +from botocore.exceptions import ClientError +from moto import mock_appsync +from moto.core import ACCOUNT_ID + +# 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 + + +@mock_appsync +def test_create_graphql_api(): + client = boto3.client("appsync", region_name="ap-southeast-1") + resp = client.create_graphql_api(name="api1", authenticationType="API_KEY") + + resp.should.have.key("graphqlApi") + + api = resp["graphqlApi"] + api.should.have.key("name").equals("api1") + api.should.have.key("apiId") + api.should.have.key("authenticationType").equals("API_KEY") + api.should.have.key("arn").equals( + f"arn:aws:appsync:ap-southeast-1:{ACCOUNT_ID}:apis/{api['apiId']}" + ) + api.should.have.key("uris").equals({"GRAPHQL": "http://graphql.uri"}) + api.should.have.key("xrayEnabled").equals(False) + api.shouldnt.have.key("additionalAuthenticationProviders") + api.shouldnt.have.key("logConfig") + + +@mock_appsync +def test_create_graphql_api_advanced(): + client = boto3.client("appsync", region_name="ap-southeast-1") + resp = client.create_graphql_api( + name="api1", + authenticationType="API_KEY", + additionalAuthenticationProviders=[{"authenticationType": "API_KEY"}], + logConfig={ + "fieldLogLevel": "ERROR", + "cloudWatchLogsRoleArn": "arn:aws:cloudwatch:role", + }, + xrayEnabled=True, + ) + + resp.should.have.key("graphqlApi") + + api = resp["graphqlApi"] + api.should.have.key("name").equals("api1") + api.should.have.key("apiId") + api.should.have.key("authenticationType").equals("API_KEY") + api.should.have.key("arn").equals( + f"arn:aws:appsync:ap-southeast-1:{ACCOUNT_ID}:apis/{api['apiId']}" + ) + api.should.have.key("uris").equals({"GRAPHQL": "http://graphql.uri"}) + api.should.have.key("additionalAuthenticationProviders").equals( + [{"authenticationType": "API_KEY"}] + ) + api.should.have.key("logConfig").equals( + {"cloudWatchLogsRoleArn": "arn:aws:cloudwatch:role", "fieldLogLevel": "ERROR"} + ) + api.should.have.key("xrayEnabled").equals(True) + + +@mock_appsync +def test_get_graphql_api(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + resp = client.get_graphql_api(apiId=api_id) + resp.should.have.key("graphqlApi") + + api = resp["graphqlApi"] + api.should.have.key("name").equals("api1") + api.should.have.key("apiId") + api.should.have.key("authenticationType").equals("API_KEY") + + +@mock_appsync +def test_update_graphql_api(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + client.update_graphql_api( + apiId=api_id, + name="api2", + authenticationType="AWS_IAM", + logConfig={ + "cloudWatchLogsRoleArn": "arn:aws:cloudwatch:role", + "fieldLogLevel": "ERROR", + }, + userPoolConfig={ + "awsRegion": "us-east-1", + "defaultAction": "DENY", + "userPoolId": "us-east-1_391729ed4a2d430a9d2abadecfc1ab86", + }, + xrayEnabled=True, + ) + + graphql_api = client.get_graphql_api(apiId=api_id)["graphqlApi"] + + graphql_api.should.have.key("name").equals("api2") + graphql_api.should.have.key("authenticationType").equals("AWS_IAM") + graphql_api.should.have.key("arn").equals( + f"arn:aws:appsync:ap-southeast-1:{ACCOUNT_ID}:apis/{graphql_api['apiId']}" + ) + graphql_api.should.have.key("logConfig").equals( + {"cloudWatchLogsRoleArn": "arn:aws:cloudwatch:role", "fieldLogLevel": "ERROR"} + ) + graphql_api.should.have.key("userPoolConfig").equals( + { + "awsRegion": "us-east-1", + "defaultAction": "DENY", + "userPoolId": "us-east-1_391729ed4a2d430a9d2abadecfc1ab86", + } + ) + graphql_api.should.have.key("xrayEnabled").equals(True) + + +@mock_appsync +def test_get_graphql_api_unknown(): + client = boto3.client("appsync", region_name="ap-southeast-1") + + with pytest.raises(ClientError) as exc: + client.get_graphql_api(apiId="unknown") + err = exc.value.response["Error"] + + err["Code"].should.equal("NotFoundException") + err["Message"].should.equal("GraphQL API unknown not found.") + + +@mock_appsync +def test_delete_graphql_api(): + client = boto3.client("appsync", region_name="eu-west-1") + + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + resp = client.list_graphql_apis() + resp.should.have.key("graphqlApis").length_of(1) + + client.delete_graphql_api(apiId=api_id) + + resp = client.list_graphql_apis() + resp.should.have.key("graphqlApis").length_of(0) + + +@mock_appsync +def test_list_graphql_apis(): + client = boto3.client("appsync", region_name="ap-southeast-1") + resp = client.list_graphql_apis() + resp.should.have.key("graphqlApis").equals([]) + + for _ in range(3): + client.create_graphql_api(name="api1", authenticationType="API_KEY") + + resp = client.list_graphql_apis() + resp.should.have.key("graphqlApis").length_of(3) diff --git a/tests/test_appsync/test_appsync_apikeys.py b/tests/test_appsync/test_appsync_apikeys.py new file mode 100644 index 000000000..fe644fbd9 --- /dev/null +++ b/tests/test_appsync/test_appsync_apikeys.py @@ -0,0 +1,112 @@ +import boto3 + +from datetime import timedelta, datetime +from moto import mock_appsync +from moto.core.utils import unix_time + + +@mock_appsync +def test_create_api_key_simple(): + client = boto3.client("appsync", region_name="eu-west-1") + + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + resp = client.create_api_key(apiId=api_id) + + resp.should.have.key("apiKey") + api_key = resp["apiKey"] + + api_key.should.have.key("id") + api_key.shouldnt.have.key("description") + api_key.should.have.key("expires") + api_key.should.have.key("deletes") + + +@mock_appsync +def test_create_api_key(): + client = boto3.client("appsync", region_name="ap-southeast-1") + tomorrow = datetime.now() + timedelta(days=1) + tomorrow_in_secs = int(unix_time(tomorrow)) + + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + resp = client.create_api_key( + apiId=api_id, description="my first api key", expires=tomorrow_in_secs + ) + + resp.should.have.key("apiKey") + api_key = resp["apiKey"] + + api_key.should.have.key("id") + api_key.should.have.key("description").equals("my first api key") + api_key.should.have.key("expires").equals(tomorrow_in_secs) + api_key.should.have.key("deletes").equals(tomorrow_in_secs) + + +@mock_appsync +def test_delete_api_key(): + client = boto3.client("appsync", region_name="us-east-1") + + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + api_key_id = client.create_api_key(apiId=api_id)["apiKey"]["id"] + + client.delete_api_key(apiId=api_id, id=api_key_id) + + resp = client.list_api_keys(apiId=api_id) + resp.should.have.key("apiKeys").length_of(0) + + +@mock_appsync +def test_list_api_keys_unknown_api(): + client = boto3.client("appsync", region_name="ap-southeast-1") + resp = client.list_api_keys(apiId="unknown") + resp.should.have.key("apiKeys").equals([]) + + +@mock_appsync +def test_list_api_keys_empty(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + resp = client.list_api_keys(apiId=api_id) + resp.should.have.key("apiKeys").equals([]) + + +@mock_appsync +def test_list_api_keys(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + client.create_api_key(apiId=api_id) + client.create_api_key(apiId=api_id, description="my first api key") + resp = client.list_api_keys(apiId=api_id) + resp.should.have.key("apiKeys").length_of(2) + + +@mock_appsync +def test_update_api_key(): + client = boto3.client("appsync", region_name="eu-west-1") + + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + original = client.create_api_key(apiId=api_id, description="my first api key")[ + "apiKey" + ] + + updated = client.update_api_key( + apiId=api_id, id=original["id"], description="my second api key" + )["apiKey"] + + updated.should.have.key("id").equals(original["id"]) + updated.should.have.key("description").equals("my second api key") + updated.should.have.key("expires").equals(original["expires"]) + updated.should.have.key("deletes").equals(original["deletes"]) diff --git a/tests/test_appsync/test_appsync_schema.py b/tests/test_appsync/test_appsync_schema.py new file mode 100644 index 000000000..b1572d79e --- /dev/null +++ b/tests/test_appsync/test_appsync_schema.py @@ -0,0 +1,88 @@ +import boto3 +import sure # noqa # pylint: disable=unused-import + +from moto import mock_appsync + +schema = """type Mutation { + putPost(id: ID!, title: String!): Post +} + +"My custom post type" +type Post { + id: ID! + title: String! +} + +type Query { + singlePost(id: ID!): Post +} + +schema { + query: Query + mutation: Mutation + +}""" + + +@mock_appsync +def test_start_schema_creation(): + client = boto3.client("appsync", region_name="us-east-2") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + resp = client.start_schema_creation(apiId=api_id, definition=b"sth") + + resp.should.have.key("status").equals("PROCESSING") + + +@mock_appsync +def test_get_schema_creation_status(): + client = boto3.client("appsync", region_name="eu-west-1") + 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_schema_creation_status(apiId=api_id) + + resp.should.have.key("status").equals("SUCCESS") + resp.shouldnt.have.key("details") + + +@mock_appsync +def test_get_schema_creation_status_invalid(): + client = boto3.client("appsync", region_name="eu-west-1") + api_id = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ]["apiId"] + + client.start_schema_creation(apiId=api_id, definition=b"sth") + resp = client.get_schema_creation_status(apiId=api_id) + + resp.should.have.key("status").equals("FAILED") + resp.should.have.key("details").match("Syntax Error") + + +@mock_appsync +def test_get_type_from_schema(): + 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_type(apiId=api_id, typeName="Post", format="SDL") + + resp.should.have.key("type") + graphql_type = resp["type"] + graphql_type.should.have.key("name").equals("Post") + graphql_type.should.have.key("description").equals("My custom post type") + graphql_type.should.have.key("arn").equals("arn:aws:appsync:graphql_type/Post") + graphql_type.should.have.key("definition").equals("NotYetImplemented") + graphql_type.should.have.key("format").equals("SDL") + + 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") diff --git a/tests/test_appsync/test_appsync_tags.py b/tests/test_appsync/test_appsync_tags.py new file mode 100644 index 000000000..b3f24ff25 --- /dev/null +++ b/tests/test_appsync/test_appsync_tags.py @@ -0,0 +1,86 @@ +import boto3 +import sure # noqa # pylint: disable=unused-import + +from moto import mock_appsync + + +@mock_appsync +def test_create_graphql_api_with_tags(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api = client.create_graphql_api( + name="api1", authenticationType="API_KEY", tags={"key": "val", "key2": "val2"} + )["graphqlApi"] + + api.should.have.key("tags").equals({"key": "val", "key2": "val2"}) + + api = client.get_graphql_api(apiId=api["apiId"])["graphqlApi"] + + api.should.have.key("tags").equals({"key": "val", "key2": "val2"}) + + +@mock_appsync +def test_tag_resource(): + client = boto3.client("appsync", region_name="us-east-2") + api = client.create_graphql_api(name="api1", authenticationType="API_KEY")[ + "graphqlApi" + ] + + client.tag_resource(resourceArn=(api["arn"]), tags={"key1": "val1"}) + + api = client.get_graphql_api(apiId=api["apiId"])["graphqlApi"] + api.should.have.key("tags").equals({"key1": "val1"}) + + +@mock_appsync +def test_tag_resource_with_existing_tags(): + client = boto3.client("appsync", region_name="us-east-2") + api = client.create_graphql_api( + name="api1", authenticationType="API_KEY", tags={"key": "val", "key2": "val2"} + )["graphqlApi"] + + client.untag_resource(resourceArn=api["arn"], tagKeys=["key"]) + + client.tag_resource( + resourceArn=(api["arn"]), tags={"key2": "new value", "key3": "val3"} + ) + + api = client.get_graphql_api(apiId=api["apiId"])["graphqlApi"] + api.should.have.key("tags").equals({"key2": "new value", "key3": "val3"}) + + +@mock_appsync +def test_untag_resource(): + client = boto3.client("appsync", region_name="eu-west-1") + api = client.create_graphql_api( + name="api1", authenticationType="API_KEY", tags={"key": "val", "key2": "val2"} + )["graphqlApi"] + + client.untag_resource(resourceArn=api["arn"], tagKeys=["key"]) + + api = client.get_graphql_api(apiId=api["apiId"])["graphqlApi"] + api.should.have.key("tags").equals({"key2": "val2"}) + + +@mock_appsync +def test_untag_resource_all(): + client = boto3.client("appsync", region_name="eu-west-1") + api = client.create_graphql_api( + name="api1", authenticationType="API_KEY", tags={"key": "val", "key2": "val2"} + )["graphqlApi"] + + client.untag_resource(resourceArn=api["arn"], tagKeys=["key", "key2"]) + + api = client.get_graphql_api(apiId=api["apiId"])["graphqlApi"] + api.should.have.key("tags").equals({}) + + +@mock_appsync +def test_list_tags_for_resource(): + client = boto3.client("appsync", region_name="ap-southeast-1") + api = client.create_graphql_api( + name="api1", authenticationType="API_KEY", tags={"key": "val", "key2": "val2"} + )["graphqlApi"] + + resp = client.list_tags_for_resource(resourceArn=api["arn"]) + + resp.should.have.key("tags").equals({"key": "val", "key2": "val2"}) diff --git a/tests/test_appsync/test_server.py b/tests/test_appsync/test_server.py new file mode 100644 index 000000000..0d32227e7 --- /dev/null +++ b/tests/test_appsync/test_server.py @@ -0,0 +1,15 @@ +import json +import sure # noqa # pylint: disable=unused-import + +import moto.server as server + + +def test_appsync_list_tags_for_resource(): + backend = server.create_backend_app("appsync") + test_client = backend.test_client() + + resp = test_client.get( + "/v1/tags/arn%3Aaws%3Aappsync%3Aus-east-1%3A123456789012%3Aapis%2Ff405dd93-855e-451d-ab00-7325b8e439c6?tagKeys=Description" + ) + resp.status_code.should.equal(200) + json.loads(resp.data).should.equals({"tags": {}})