add decorator for adding routes to a Router
diff --git a/localstack/http/__init__.py b/localstack/http/__init__.py
index 9863290dab076..50bd54f3ecf35 100644
--- a/localstack/http/__init__.py
+++ b/localstack/http/__init__.py
@@ -1,5 +1,5 @@
 from .request import Request
 from .response import Response
-from .router import Router
+from .router import Router, route
 
-__all__ = ["Router", "Response", "Request"]
+__all__ = ["Router", "route", "Response", "Request"]
diff --git a/localstack/http/router.py b/localstack/http/router.py
index 7d9ebba89152b..e4aab7761c6c7 100644
--- a/localstack/http/router.py
+++ b/localstack/http/router.py
@@ -1,5 +1,20 @@
+import functools
+import inspect
 import threading
-from typing import Any, Callable, Generic, Iterable, Mapping, Optional, Protocol, TypeVar
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Generic,
+    Iterable,
+    List,
+    Mapping,
+    NamedTuple,
+    Optional,
+    Protocol,
+    Type,
+    TypeVar,
+)
 
 from werkzeug.routing import BaseConverter, Map, Rule, RuleFactory
 
@@ -12,7 +27,7 @@
 RequestArguments = Mapping[str, Any]
 
 
-class Dispatcher(Protocol):
+class Dispatcher(Protocol[E]):
     """
     A Dispatcher is called when a URL route matches a request. The dispatcher is responsible for appropriately
     creating a Response from the incoming Request and the matching endpoint.
@@ -30,6 +45,49 @@ def __call__(self, request: Request, endpoint: E, args: RequestArguments) -> Res
         pass
 
 
+class _RuleAttributes(NamedTuple):
+    path: str
+    host: Optional[str] = (None,)
+    methods: Optional[Iterable[str]] = None
+    kwargs: Optional[Dict[str, Any]] = {}
+
+
+class _RouteEndpoint(Protocol):
+    """
+    An endpoint that encapsulates ``_RuleAttributes`` for the creation of a ``Rule`` inside a ``Router``.
+    """
+
+    rule_attributes: _RuleAttributes
+
+    def __call__(self, *args, **kwargs):
+        raise NotImplementedError
+
+
+def route(
+    path: str, host: Optional[str] = None, methods: Optional[Iterable[str]] = None, **kwargs
+) -> Callable[[E], _RouteEndpoint]:
+    """
+    Decorator that indicates that the given function is a Router Rule.
+
+    :param path: the path pattern to match
+    :param host: an optional host matching pattern. if not pattern is given, the rule matches any host
+    :param methods: the allowed HTTP verbs for this rule
+    :param kwargs: any other argument that can be passed to ``werkzeug.routing.Rule``
+    :return: the function endpoint wrapped as a ``_RouteEndpoint``
+    """
+
+    def wrapper(fn: E):
+        @functools.wraps(fn)
+        def route_marker(*args, **kwargs):
+            return fn(*args, **kwargs)
+
+        route_marker.rule_attributes = _RuleAttributes(path, host, methods, kwargs)
+
+        return route_marker
+
+    return wrapper
+
+
 def call_endpoint(
     request: Request,
     endpoint: Callable[[Request, RequestArguments], Response],
@@ -63,10 +121,10 @@ class Router(Generic[E]):
     """
 
     url_map: Map
-    dispatcher: Dispatcher
+    dispatcher: Dispatcher[E]
 
     def __init__(
-        self, dispatcher: Dispatcher = None, converters: Mapping[str, BaseConverter] = None
+        self, dispatcher: Dispatcher[E] = None, converters: Mapping[str, Type[BaseConverter]] = None
     ):
         self.url_map = Map(host_matching=True, strict_slashes=False, converters=converters)
         self.dispatcher = dispatcher or call_endpoint
@@ -101,6 +159,31 @@ def add(
         self.add_rule(rule)
         return rule
 
+    def add_route_endpoint(self, fn: _RouteEndpoint) -> Rule:
+        """
+        Adds a RouteEndpoint (typically a function decorated with ``@route``) as a rule to the router.
+        :param fn: the RouteEndpoint function
+        :return: the rule that was added
+        """
+        attr: _RuleAttributes = fn.rule_attributes
+
+        return self.add(path=attr.path, endpoint=fn, host=attr.host, **attr.kwargs)
+
+    def add_route_endpoints(self, obj: object) -> List[Rule]:
+        """
+        Scans the given object for members that can be used as a `RouteEndpoint` and adds them to the router.
+        :param obj: the object to scan
+        :return: the rules that were added
+        """
+        rules = []
+
+        members = inspect.getmembers(obj)
+        for _, member in members:
+            if hasattr(member, "rule_attributes"):
+                rules.append(self.add_route_endpoint(member))
+
+        return rules
+
     def add_rule(self, rule: RuleFactory):
         with self._mutex:
             self.url_map.add(rule)
@@ -148,6 +231,32 @@ def dispatch(self, request: Request) -> Response:
         args.pop("__host__", None)
         return self.dispatcher(request, handler, args)
 
+    def route(
+        self,
+        path: str,
+        host: Optional[str] = None,
+        methods: Optional[Iterable[str]] = None,
+        **kwargs,
+    ) -> Callable[[E], _RouteEndpoint]:
+        """
+        Returns a ``route`` decorator and immediately adds it to the router instance. This effectively mimics flask's
+        ``@app.route``.
+
+        :param path: the path pattern to match
+        :param host: an optional host matching pattern. if not pattern is given, the rule matches any host
+        :param methods: the allowed HTTP verbs for this rule
+        :param kwargs: any other argument that can be passed to ``werkzeug.routing.Rule``
+        :return: the function endpoint wrapped as a ``_RouteEndpoint``
+        """
+
+        def wrapper(fn):
+            r = route(path, host, methods, **kwargs)
+            fn = r(fn)
+            self.add_route_endpoint(fn)
+            return fn
+
+        return wrapper
+
 
 class RegexConverter(BaseConverter):
     def __init__(self, map: "Map", *args: Any, **kwargs: Any) -> None:
diff --git a/tests/unit/http/test_router.py b/tests/unit/http/test_router.py
index c23a8180a02a5..b9bf88c1ae5f5 100644
--- a/tests/unit/http/test_router.py
+++ b/tests/unit/http/test_router.py
@@ -7,7 +7,7 @@
 from werkzeug.exceptions import NotFound
 
 from localstack.http import Request, Response, Router
-from localstack.http.router import E, RegexConverter, RequestArguments
+from localstack.http.router import E, RegexConverter, RequestArguments, route
 from localstack.utils.common import get_free_tcp_port
 
 
@@ -166,6 +166,61 @@ def users(_: Request, args) -> Response:
         with pytest.raises(NotFound):
             assert router.dispatch(Request("GET", "/users/12"))
 
+    def test_remove_non_existing_rule(self):
+        router = Router()
+
+        def index(_: Request, args) -> Response:
+            return Response(b"index")
+
+        rule = router.add("/", index)
+        router.remove_rule(rule)
+
+        with pytest.raises(KeyError) as e:
+            router.remove_rule(rule)
+        e.match("no such rule")
+
+    def test_router_route_decorator(self):
+        router = Router()
+
+        @router.route("/users")
+        def user(_: Request, args):
+            assert not args
+            return Response("user")
+
+        @router.route("/users/<int:user_id>")
+        def user_id(_: Request, args):
+            assert args
+            return Response(f"{args['user_id']}")
+
+        assert router.dispatch(Request("GET", "/users")).data == b"user"
+        assert router.dispatch(Request("GET", "/users/123")).data == b"123"
+
+    def test_add_route_endpoint_with_object(self):
+        class MySuperApi:
+            @route("/users")
+            def user(self, _: Request, args):
+                # should be inherited
+                assert not args
+                return Response("user")
+
+        class MyApi(MySuperApi):
+            @route("/users/<int:user_id>")
+            def user_id(self, _: Request, args):
+                assert args
+                return Response(f"{args['user_id']}")
+
+            def foo(self, _: Request, args):
+                # should be ignored
+                raise NotImplementedError
+
+        api = MyApi()
+        router = Router()
+        rules = router.add_route_endpoints(api)
+        assert len(rules) == 2
+
+        assert router.dispatch(Request("GET", "/users")).data == b"user"
+        assert router.dispatch(Request("GET", "/users/123")).data == b"123"
+
 
 class TestWsgiIntegration:
     def test_with_werkzeug(self):

This PR adds a decorator for the `Router` to add rules more easily and build cohesion between handlers and their routes. This can be used to simplify adding routes to the edge router, or other routers, which will appear more and more in service providers. Specifically, I'll use this to build the SQS Query API.

How to use it:

## Bind functions directly to a Router
This is essentially like flask's `@app.route` decorator. It will add the endpoint directly to the `router` instance. The function signature needs to match the expected input of the dispatcher configured for the Router (in this case it needs to match the `Handler` protocol)

```python
router: Router[Handler] = Router(dispatcher=handler_dispatcher())

@router.route("/user/<user_id>")
def my_route(request, user_id):
    return {}
```

## Define unbound routes and then bind them to a router

This is required when you want do add bound methods to a router. This is similar to flask's blueprints.

```python
class MyService:
    @route("/user/<user_id>")
    def my_route(request, user_id):
        return {}

my_service = MyService()

router = Router(dispatcher=handler_dispatcher())
router.add_route_endpoint(my_service.my_route)
```

## Batch bind routes of a class to a router

```python
class MyService:
    @route("/user/<user_id>")
    def get_user(request, user_id):
        return {}

    @route("/user")
    def list_user(request):
        return []

router = Router(dispatcher=handler_dispatcher())
router.add_route_endpoints(MyService())
```