Repository: getmoto/moto (python)

Issue:
Feature request: `ApiGatewayManagementApi` service mock
Hey folks! This library is dope, thank you!

Just wanted to put my hat in for `ApiGatewayManagementApi` (aka `apigatewaymanagementapi`) support.

- See: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/apigatewaymanagementapi.html
- Sadly I'm not sure it's (fully) documented, but it's stable – as in, it hasn't changed for years. I mocked some key for a non-Python project here, if that's of interest: https://github.com/architect/sandbox/blob/batch/src/arc/_ws/index.js
- Some possibly helpful information here, too: https://github.com/aws/aws-sdk-js/blob/307e82673b48577fce4389e4ce03f95064e8fe0d/apis/apigatewaymanagementapi-2018-11-29.normal.json

Thanks for your consideration!

Pull request description:
Feature: APIGateway Management API
Fixes #6532 

Patch:
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
 </details>
 
+## apigatewaymanagementapi
+<details>
+<summary>100% implemented</summary>
+
+- [X] delete_connection
+- [X] get_connection
+- [X] post_to_connection
+</details>
+
 ## apigatewayv2
 <details>
 <summary>75% implemented</summary>
@@ -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
+
+    <h3>
+
+.. |end-h3| raw:: html
+
+    </h3>
+
+=======================
+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<connection_id>[^/]+)$": 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


Test patch:
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"