diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 0dadd1855..c787190af 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -202,6 +202,15 @@ - [ ] update_vpc_link +## apigatewaymanagementapi +
+100% implemented + +- [X] delete_connection +- [X] get_connection +- [X] post_to_connection +
+ ## apigatewayv2
75% implemented @@ -7246,7 +7255,6 @@ - amplify - amplifybackend - amplifyuibuilder -- apigatewaymanagementapi - appconfigdata - appfabric - appflow diff --git a/docs/docs/services/apigatewaymanagementapi.rst b/docs/docs/services/apigatewaymanagementapi.rst new file mode 100644 index 000000000..505438b48 --- /dev/null +++ b/docs/docs/services/apigatewaymanagementapi.rst @@ -0,0 +1,33 @@ +.. _implementedservice_apigatewaymanagementapi: + +.. |start-h3| raw:: html + +

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

+ +======================= +apigatewaymanagementapi +======================= + +.. autoclass:: moto.apigatewaymanagementapi.models.ApiGatewayManagementApiBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_apigatewaymanagementapi + def test_apigatewaymanagementapi_behaviour: + boto3.client("apigatewaymanagementapi") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [X] delete_connection +- [X] get_connection +- [X] post_to_connection + diff --git a/moto/__init__.py b/moto/__init__.py index a16d598d3..18e69c159 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -29,6 +29,9 @@ mock_acm = lazy_load(".acm", "mock_acm") mock_acmpca = lazy_load(".acmpca", "mock_acmpca", boto3_name="acm-pca") mock_amp = lazy_load(".amp", "mock_amp") mock_apigateway = lazy_load(".apigateway", "mock_apigateway") +mock_apigatewaymanagementapi = lazy_load( + ".apigatewaymanagementapi", "mock_apigatewaymanagementapi" +) mock_apigatewayv2 = lazy_load(".apigatewayv2", "mock_apigatewayv2") mock_appconfig = lazy_load(".appconfig", "mock_appconfig") mock_appsync = lazy_load(".appsync", "mock_appsync") diff --git a/moto/apigatewaymanagementapi/__init__.py b/moto/apigatewaymanagementapi/__init__.py new file mode 100644 index 000000000..0c986e2bd --- /dev/null +++ b/moto/apigatewaymanagementapi/__init__.py @@ -0,0 +1,5 @@ +"""apigatewaymanagementapi module initialization; sets value for base decorator.""" +from .models import apigatewaymanagementapi_backends +from ..core.models import base_decorator + +mock_apigatewaymanagementapi = base_decorator(apigatewaymanagementapi_backends) diff --git a/moto/apigatewaymanagementapi/models.py b/moto/apigatewaymanagementapi/models.py new file mode 100644 index 000000000..9ea9b408b --- /dev/null +++ b/moto/apigatewaymanagementapi/models.py @@ -0,0 +1,48 @@ +"""ApiGatewayManagementApiBackend class with methods for supported APIs.""" +from collections import defaultdict +from typing import Any, Dict +from moto.core import BaseBackend, BackendDict +from moto.core.utils import unix_time + + +class Connection: + def __init__(self) -> None: + self.connected_at = unix_time() + self.source_ip = "192.168.0.1" + self.user_agent = "Moto Mocks" + self.data = b"" + + def to_dict(self) -> Dict[str, Any]: + return { + "connectedAt": self.connected_at, + "lastActiveAt": unix_time(), + "identity": { + "sourceIp": self.source_ip, + "userAgent": self.user_agent, + }, + } + + +class ApiGatewayManagementApiBackend(BaseBackend): + """ + Connecting to this API in ServerMode/Docker requires Python >= 3.8 and an up-to-date `werkzeug` version (>=2.3.x) + """ + + def __init__(self, region_name: str, account_id: str): + super().__init__(region_name, account_id) + self.connections: Dict[str, Connection] = defaultdict(Connection) + + def delete_connection(self, connection_id: str) -> None: + self.connections.pop(connection_id, None) + + def get_connection(self, connection_id: str) -> Connection: + return self.connections[connection_id] + + def post_to_connection(self, data: bytes, connection_id: str) -> None: + cnctn = self.get_connection(connection_id) + cnctn.data += data + + +apigatewaymanagementapi_backends = BackendDict( + ApiGatewayManagementApiBackend, "apigateway" +) diff --git a/moto/apigatewaymanagementapi/responses.py b/moto/apigatewaymanagementapi/responses.py new file mode 100644 index 000000000..1accf478f --- /dev/null +++ b/moto/apigatewaymanagementapi/responses.py @@ -0,0 +1,46 @@ +"""Handles incoming apigatewaymanagementapi requests, invokes methods, returns responses.""" +import json +from typing import Any + +from moto.core.responses import BaseResponse +from .models import apigatewaymanagementapi_backends, ApiGatewayManagementApiBackend + + +class ApiGatewayManagementApiResponse(BaseResponse): + """Handler for ApiGatewayManagementApi requests and responses.""" + + def __init__(self) -> None: + super().__init__(service_name="apigatewaymanagementapi") + + def setup_class( + self, request: Any, full_url: str, headers: Any, use_raw_body: bool = False + ) -> None: + super().setup_class(request, full_url, headers, use_raw_body=True) + + @property + def apigatewaymanagementapi_backend(self) -> ApiGatewayManagementApiBackend: + """Return backend instance specific for this region.""" + return apigatewaymanagementapi_backends[self.current_account][self.region] + + def delete_connection(self) -> str: + connection_id = self.path.split("/@connections/")[-1] + self.apigatewaymanagementapi_backend.delete_connection( + connection_id=connection_id + ) + return "{}" + + def get_connection(self) -> str: + connection_id = self.path.split("/@connections/")[-1] + connection = self.apigatewaymanagementapi_backend.get_connection( + connection_id=connection_id + ) + return json.dumps(connection.to_dict()) + + def post_to_connection(self) -> str: + connection_id = self.path.split("/@connections/")[-1] + data = self.body + self.apigatewaymanagementapi_backend.post_to_connection( + data=data, + connection_id=connection_id, + ) + return "{}" diff --git a/moto/apigatewaymanagementapi/urls.py b/moto/apigatewaymanagementapi/urls.py new file mode 100644 index 000000000..f4a611047 --- /dev/null +++ b/moto/apigatewaymanagementapi/urls.py @@ -0,0 +1,12 @@ +"""apigatewaymanagementapi base URL and path.""" +from .responses import ApiGatewayManagementApiResponse + +url_bases = [r"https?://execute-api\.(.+)\.amazonaws\.com"] + + +response = ApiGatewayManagementApiResponse() + + +url_paths = { + "{0}/@connections/(?P[^/]+)$": response.dispatch, +} diff --git a/moto/backend_index.py b/moto/backend_index.py index 477175fb2..5f57542fb 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -6,6 +6,10 @@ backend_url_patterns = [ ("acm-pca", re.compile("https?://acm-pca\\.(.+)\\.amazonaws\\.com")), ("amp", re.compile("https?://aps\\.(.+)\\.amazonaws\\.com")), ("apigateway", re.compile("https?://apigateway\\.(.+)\\.amazonaws.com")), + ( + "apigatewaymanagementapi", + re.compile("https?://execute-api\\.(.+)\\.amazonaws\\.com"), + ), ("appconfig", re.compile("https?://appconfig\\.(.+)\\.amazonaws\\.com")), ( "applicationautoscaling", @@ -110,12 +114,9 @@ backend_url_patterns = [ ), ( "meteringmarketplace", - re.compile("https?://metering.marketplace\\.(.+)\\.amazonaws.com"), - ), - ( - "meteringmarketplace", - re.compile("https?://aws-marketplace\\.(.+)\\.amazonaws.com"), + re.compile("https?://metering.marketplace.(.+).amazonaws.com"), ), + ("meteringmarketplace", re.compile("https?://aws-marketplace.(.+).amazonaws.com")), ("mq", re.compile("https?://mq\\.(.+)\\.amazonaws\\.com")), ("opsworks", re.compile("https?://opsworks\\.us-east-1\\.amazonaws.com")), ("organizations", re.compile("https?://organizations\\.(.+)\\.amazonaws\\.com")), diff --git a/moto/moto_server/werkzeug_app.py b/moto/moto_server/werkzeug_app.py index 1d196656a..5dea96f0c 100644 --- a/moto/moto_server/werkzeug_app.py +++ b/moto/moto_server/werkzeug_app.py @@ -100,7 +100,14 @@ class DomainDispatcherApplication: try: credential_scope = auth.split(",")[0].split()[1] _, _, region, service, _ = credential_scope.split("/") - service = SIGNING_ALIASES.get(service.lower(), service) + path = environ.get("PATH_INFO", "") + if service.lower() == "execute-api" and path.startswith( + "/@connections" + ): + # APIGateway Management API + pass + else: + service = SIGNING_ALIASES.get(service.lower(), service) service = service.lower() except ValueError: # Signature format does not match, this is exceptional and we can't diff --git a/tests/test_apigatewaymanagementapi/__init__.py b/tests/test_apigatewaymanagementapi/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_apigatewaymanagementapi/test_apigatewaymanagementapi.py b/tests/test_apigatewaymanagementapi/test_apigatewaymanagementapi.py new file mode 100644 index 000000000..19f4c0839 --- /dev/null +++ b/tests/test_apigatewaymanagementapi/test_apigatewaymanagementapi.py @@ -0,0 +1,56 @@ +"""Unit tests for apigatewaymanagementapi-supported APIs.""" +import boto3 + +from moto import mock_apigatewaymanagementapi, settings +from moto.core.versions import is_werkzeug_2_3_x +from moto.apigatewaymanagementapi.models import apigatewaymanagementapi_backends +from tests import DEFAULT_ACCOUNT_ID +from unittest import SkipTest + +# 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_apigatewaymanagementapi +def test_delete_connection(): + if settings.TEST_SERVER_MODE and not is_werkzeug_2_3_x(): + # URL matching changed between 2.2.x and 2.3.x + # 2.3.x has no problem matching the root path '/@connections', but 2.2.x refuses + raise SkipTest("Can't test this in older werkzeug versions") + client = boto3.client("apigatewaymanagementapi", region_name="eu-west-1") + # NO-OP + client.delete_connection(ConnectionId="anything") + + +@mock_apigatewaymanagementapi +def test_get_connection(): + if settings.TEST_SERVER_MODE and not is_werkzeug_2_3_x(): + # URL matching changed between 2.2.x and 2.3.x + # 2.3.x has no problem matching the root path '/@connections', but 2.2.x refuses + raise SkipTest("Can't test this in older werkzeug versions") + client = boto3.client("apigatewaymanagementapi", region_name="us-east-2") + conn = client.get_connection(ConnectionId="anything") + + assert "ConnectedAt" in conn + assert conn["Identity"] == {"SourceIp": "192.168.0.1", "UserAgent": "Moto Mocks"} + assert "LastActiveAt" in conn + + +@mock_apigatewaymanagementapi +def test_post_to_connection(): + if settings.TEST_SERVER_MODE and not is_werkzeug_2_3_x(): + # URL matching changed between 2.2.x and 2.3.x + # 2.3.x has no problem matching the root path '/@connections', but 2.2.x refuses + raise SkipTest("Can't test this in older werkzeug versions") + client = boto3.client("apigatewaymanagementapi", region_name="ap-southeast-1") + client.post_to_connection(ConnectionId="anything", Data=b"my first bytes") + + if not settings.TEST_SERVER_MODE: + backend = apigatewaymanagementapi_backends[DEFAULT_ACCOUNT_ID]["ap-southeast-1"] + assert backend.connections["anything"].data == b"my first bytes" + + client.post_to_connection(ConnectionId="anything", Data=b"more bytes") + + if not settings.TEST_SERVER_MODE: + backend = apigatewaymanagementapi_backends[DEFAULT_ACCOUNT_ID]["ap-southeast-1"] + assert backend.connections["anything"].data == b"my first bytesmore bytes"