Repository: getmoto/moto
Pull request #8040 (2024-08-27 14:19:27)

Resolve a `DeprecationWarning` due to an invalid string escape
CI is throwing a warning about an invalid string escape (`"\ "`) ([recent example](https://github.com/getmoto/moto/actions/runs/10570098846/job/29284013829#step:7:849)).

This PR addresses the `DeprecationWarning` by updating the test string to `"\\ "`.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/8040?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.41%. Comparing base [(`5581aa4`)](https://app.codecov.io/gh/getmoto/moto/commit/5581aa4aea019550d2d07395a14b6d24d40d8829?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`511f3d0`)](https://app.codecov.io/gh/getmoto/moto/commit/511f3d09b95d547a97b1398a382df7e5de430b70?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #8040   +/-   ##
=======================================
  Coverage   94.41%   94.41%           
=======================================
  Files        1134     1134           
  Lines       96832    96832           
=======================================
  Hits        91424    91424           
  Misses       5408     5408           
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/8040/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/8040/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `28.99% <ø> (ø)` | |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/8040/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.38% <ø> (ø)` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/8040?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/moto/appmesh/dataclasses/mesh.py b/moto/appmesh/dataclasses/mesh.py
new file mode 100644
index 000000000..b50921576
--- /dev/null
+++ b/moto/appmesh/dataclasses/mesh.py
@@ -0,0 +1,44 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Literal, Optional
+
+from moto.appmesh.dataclasses.shared import Metadata, Status
+from moto.appmesh.dataclasses.virtual_router import VirtualRouter
+
+
+@dataclass
+class MeshSpec:
+    egress_filter: Dict[Literal["type"], Optional[str]]
+    service_discovery: Dict[Literal["ip_preference"], Optional[str]]
+
+
+@dataclass
+class Mesh:
+    mesh_name: str
+    metadata: Metadata
+    spec: MeshSpec
+    status: Status
+    tags: List[Dict[str, str]]
+    virtual_routers: Dict[str, VirtualRouter] = field(default_factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:  # type ignore[misc]
+        return {
+            "meshName": self.mesh_name,
+            "metadata": {
+                "arn": self.metadata.arn,
+                "createdAt": self.metadata.created_at.strftime("%d/%m/%Y, %H:%M:%S"),
+                "lastUpdatedAt": self.metadata.last_updated_at.strftime(
+                    "%d/%m/%Y, %H:%M:%S"
+                ),
+                "meshOwner": self.metadata.mesh_owner,
+                "resourceOwner": self.metadata.resource_owner,
+                "uid": self.metadata.uid,
+                "version": self.metadata.version,
+            },
+            "spec": {
+                "egressFilter": self.spec.egress_filter,
+                "serviceDiscovery": {
+                    "ipPreference": self.spec.service_discovery.get("ip_preference")
+                },
+            },
+            "status": self.status,
+        }
diff --git a/moto/appmesh/dataclasses/route.py b/moto/appmesh/dataclasses/route.py
new file mode 100644
index 000000000..768372662
--- /dev/null
+++ b/moto/appmesh/dataclasses/route.py
@@ -0,0 +1,354 @@
+from dataclasses import asdict, dataclass, field
+from typing import Any, Dict, List, Optional
+
+from moto.appmesh.dataclasses.shared import Metadata, Status
+
+
+def clean_dict(obj: Dict[str, Any]) -> Dict[str, Any]:  # type: ignore[misc]
+    return {
+        key: value for key, value in obj.items() if value is not None and value != []
+    }
+
+
+class MissingField:
+    def to_dict(self) -> None:
+        return
+
+
+@dataclass
+class TimeValue:
+    unit: str
+    value: int
+    to_dict = asdict
+
+
+@dataclass
+class RouteActionWeightedTarget:
+    virtual_node: str
+    weight: int
+    port: Optional[int] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {"port": self.port, "virtualNode": self.virtual_node, "weight": self.weight}
+        )
+
+
+@dataclass
+class RouteAction:
+    weighted_targets: List[RouteActionWeightedTarget]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {"weightedTargets": [target.to_dict() for target in self.weighted_targets]}
+        )
+
+
+@dataclass
+class Range:
+    start: int
+    end: int
+    to_dict = asdict
+
+
+@dataclass
+class Match:
+    exact: Optional[str]
+    prefix: Optional[str]
+    range: Optional[Range]
+    regex: Optional[str]
+    suffix: Optional[str]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "exact": self.exact,
+                "prefix": self.prefix,
+                "range": (self.range or MissingField()).to_dict(),
+                "regex": self.regex,
+                "suffix": self.suffix,
+            }
+        )
+
+
+@dataclass
+class GrpcMetadatum:
+    invert: Optional[bool]
+    match: Optional[Match]
+    name: str
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "invert": self.invert,
+                "match": (self.match or MissingField()).to_dict(),
+                "name": self.name,
+            }
+        )
+
+
+# same object, just different name
+HttpRouteMatchHeader = GrpcMetadatum
+
+
+@dataclass
+class RouteMatchPath:
+    exact: str
+    regex: str
+    to_dict = asdict
+
+
+@dataclass
+class QueryParameterMatch:
+    exact: str
+    to_dict = asdict
+
+
+@dataclass
+class RouteMatchQueryParameter:
+    name: str
+    match: Optional[QueryParameterMatch] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {"match": (self.match or MissingField()).to_dict(), "name": self.name}
+        )
+
+
+@dataclass
+class HttpRouteMatch:
+    headers: Optional[List[HttpRouteMatchHeader]] = field(default=None)
+    method: Optional[str] = field(default=None)
+    path: Optional[RouteMatchPath] = field(default=None)
+    port: Optional[int] = field(default=None)
+    prefix: Optional[str] = field(default=None)
+    query_parameters: Optional[List[RouteMatchQueryParameter]] = field(default=None)
+    scheme: Optional[str] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "headers": [header.to_dict() for header in self.headers or []],
+                "method": self.method,
+                "path": (self.path or MissingField()).to_dict(),
+                "port": self.port,
+                "prefix": self.prefix,
+                "queryParameters": [
+                    param.to_dict() for param in self.query_parameters or []
+                ],
+                "scheme": self.scheme,
+            }
+        )
+
+
+@dataclass
+class HttpRouteRetryPolicy:
+    max_retries: int
+    http_retry_events: Optional[List[str]]
+    per_retry_timeout: TimeValue
+    tcp_retry_events: Optional[List[str]]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "httpRetryEvents": self.http_retry_events or [],
+                "maxRetries": self.max_retries,
+                "perRetryTimeout": self.per_retry_timeout.to_dict(),
+                "tcpRetryEvents": self.tcp_retry_events or [],
+            }
+        )
+
+
+@dataclass
+class Timeout:
+    idle: Optional[TimeValue] = field(default=None)
+    per_request: Optional[TimeValue] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "idle": (self.idle or MissingField()).to_dict(),
+                "perRequest": (self.per_request or MissingField()).to_dict(),
+            }
+        )
+
+
+@dataclass
+class GrpcRouteMatch:
+    metadata: Optional[List[GrpcMetadatum]]
+    method_name: Optional[str]
+    port: Optional[int]
+    service_name: Optional[str]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "metadata": [meta.to_dict() for meta in self.metadata or []],
+                "methodName": self.method_name,
+                "port": self.port,
+                "serviceName": self.service_name,
+            }
+        )
+
+
+@dataclass
+class GrcpRouteRetryPolicy:
+    max_retries: int
+    per_retry_timeout: TimeValue
+    grpc_retry_events: Optional[List[str]]
+    http_retry_events: Optional[List[str]]
+    tcp_retry_events: Optional[List[str]]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "grpcRetryEvents": self.grpc_retry_events or [],
+                "httpRetryEvents": self.http_retry_events or [],
+                "maxRetries": self.max_retries,
+                "perRetryTimeout": self.per_retry_timeout.to_dict(),
+                "tcpRetryEvents": self.tcp_retry_events or [],
+            }
+        )
+
+
+@dataclass
+class GrpcRoute:
+    action: RouteAction
+    match: GrpcRouteMatch
+    retry_policy: Optional[GrcpRouteRetryPolicy]
+    timeout: Optional[Timeout]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "action": self.action.to_dict(),
+                "match": self.match.to_dict(),
+                "retryPolicy": (self.retry_policy or MissingField()).to_dict(),
+                "timeout": (self.timeout or MissingField()).to_dict(),
+            }
+        )
+
+
+@dataclass
+class HttpRoute:
+    action: RouteAction
+    match: HttpRouteMatch
+    retry_policy: Optional[HttpRouteRetryPolicy] = field(default=None)
+    timeout: Optional[Timeout] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "action": self.action.to_dict(),
+                "match": self.match.to_dict(),
+                "retryPolicy": (self.retry_policy or MissingField()).to_dict(),
+                "timeout": (self.timeout or MissingField()).to_dict(),
+            }
+        )
+
+
+@dataclass
+class TCPRouteMatch:
+    port: int
+    to_dict = asdict
+
+
+@dataclass
+class TCPRoute:
+    action: RouteAction
+    match: Optional[TCPRouteMatch]
+    timeout: Optional[Timeout]
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "action": self.action.to_dict(),
+                "match": (self.match or MissingField()).to_dict(),
+                "timeout": (self.timeout or MissingField()).to_dict(),
+            }
+        )
+
+
+@dataclass
+class RouteSpec:
+    priority: Optional[int]
+    grpc_route: Optional[GrpcRoute] = field(default=None)
+    http_route: Optional[HttpRoute] = field(default=None)
+    http2_route: Optional[HttpRoute] = field(default=None)
+    tcp_route: Optional[TCPRoute] = field(default=None)
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        spec = {
+            "grpcRoute": (self.grpc_route or MissingField()).to_dict(),
+            "httpRoute": (self.http_route or MissingField()).to_dict(),
+            "http2Route": (self.http2_route or MissingField()).to_dict(),
+            "priority": self.priority,
+            "tcpRoute": (self.tcp_route or MissingField()).to_dict(),
+        }
+        return clean_dict(spec)
+
+
+@dataclass
+class RouteMetadata(Metadata):
+    mesh_name: str = field(default="")
+    route_name: str = field(default="")
+    virtual_router_name: str = field(default="")
+
+    def __post_init__(self) -> None:
+        if self.mesh_name == "":
+            raise TypeError("__init__ missing 1 required argument: 'mesh_name'")
+        if self.mesh_owner == "":
+            raise TypeError("__init__ missing 1 required argument: 'route_name'")
+        if self.virtual_router_name == "":
+            raise TypeError(
+                "__init__ missing 1 required argument: 'virtual_router_name'"
+            )
+
+    def formatted_for_list_api(self) -> Dict[str, Any]:  # type: ignore
+        return {
+            "arn": self.arn,
+            "createdAt": self.created_at.strftime("%d/%m/%Y, %H:%M:%S"),
+            "lastUpdatedAt": self.last_updated_at.strftime("%d/%m/%Y, %H:%M:%S"),
+            "meshName": self.mesh_name,
+            "meshOwner": self.mesh_owner,
+            "resourceOwner": self.resource_owner,
+            "routeName": self.route_name,
+            "version": self.version,
+            "virtualRouterName": self.virtual_router_name,
+        }
+
+    def formatted_for_crud_apis(self) -> Dict[str, Any]:  # type: ignore
+        return {
+            "arn": self.arn,
+            "createdAt": self.created_at.strftime("%d/%m/%Y, %H:%M:%S"),
+            "lastUpdatedAt": self.last_updated_at.strftime("%d/%m/%Y, %H:%M:%S"),
+            "meshOwner": self.mesh_owner,
+            "resourceOwner": self.resource_owner,
+            "uid": self.uid,
+            "version": self.version,
+        }
+
+
+@dataclass
+class Route:
+    mesh_name: str
+    mesh_owner: str
+    metadata: RouteMetadata
+    route_name: str
+    spec: RouteSpec
+    tags: Optional[List[Dict[str, str]]]
+    virtual_router_name: str
+    status: Status = field(default_factory=lambda: {"status": "ACTIVE"})
+
+    def to_dict(self) -> Dict[str, Any]:  # type: ignore[misc]
+        return clean_dict(
+            {
+                "meshName": self.mesh_name,
+                "metadata": self.metadata.formatted_for_crud_apis(),
+                "routeName": self.route_name,
+                "spec": self.spec.to_dict(),
+                "status": self.status,
+                "tags": self.tags,
+                "virtualRouterName": self.virtual_router_name,
+            }
+        )
diff --git a/moto/appmesh/dataclasses/shared.py b/moto/appmesh/dataclasses/shared.py
new file mode 100644
index 000000000..48552ba3d
--- /dev/null
+++ b/moto/appmesh/dataclasses/shared.py
@@ -0,0 +1,21 @@
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Dict, Literal
+from uuid import uuid4
+
+
+@dataclass
+class Metadata:
+    arn: str
+    mesh_owner: str
+    resource_owner: str
+    created_at: datetime = datetime.now()
+    last_updated_at: datetime = datetime.now()
+    uid: str = uuid4().hex
+    version: int = 1
+
+    def update_timestamp(self) -> None:
+        self.last_updated_at = datetime.now()
+
+
+Status = Dict[Literal["status"], str]
diff --git a/moto/appmesh/dataclasses/virtual_router.py b/moto/appmesh/dataclasses/virtual_router.py
new file mode 100644
index 000000000..956627ab3
--- /dev/null
+++ b/moto/appmesh/dataclasses/virtual_router.py
@@ -0,0 +1,56 @@
+from dataclasses import dataclass, field
+from typing import Any, Dict, List, Literal, Optional
+
+from moto.appmesh.dataclasses.route import Route
+from moto.appmesh.dataclasses.shared import Metadata, Status
+
+
+@dataclass
+class PortMapping:
+    port: Optional[int]
+    protocol: Optional[str]
+
+
+@dataclass
+class VirtualRouterSpec:
+    listeners: List[Dict[Literal["port_mapping"], PortMapping]]
+
+
+@dataclass
+class VirtualRouter:
+    mesh_name: str
+    metadata: Metadata
+    spec: VirtualRouterSpec
+    status: Status
+    virtual_router_name: str
+    tags: List[Dict[str, str]]
+    routes: Dict[str, Route] = field(default_factory=dict)
+
+    def to_dict(self) -> Dict[str, Any]:  # type ignore[misc]
+        return {
+            "meshName": self.mesh_name,
+            "metadata": {
+                "arn": self.metadata.arn,
+                "createdAt": self.metadata.created_at.strftime("%d/%m/%Y, %H:%M:%S"),
+                "lastUpdatedAt": self.metadata.last_updated_at.strftime(
+                    "%d/%m/%Y, %H:%M:%S"
+                ),
+                "meshOwner": self.metadata.mesh_owner,
+                "resourceOwner": self.metadata.resource_owner,
+                "uid": self.metadata.uid,
+                "version": self.metadata.version,
+            },
+            "spec": {
+                "listeners": [
+                    {
+                        "portMapping": {
+                            "port": listener["port_mapping"].port,
+                            "protocol": listener["port_mapping"].protocol,
+                        }
+                    }
+                    for listener in self.spec.listeners
+                ]
+            },
+            "status": self.status,
+            "virtualRouterName": self.virtual_router_name,
+        }
diff --git a/moto/appmesh/exceptions.py b/moto/appmesh/exceptions.py
index 44c2164f0..afdd24e3a 100644
--- a/moto/appmesh/exceptions.py
+++ b/moto/appmesh/exceptions.py
@@ -21,3 +21,47 @@ class ResourceNotFoundError(MeshError):
             "ResourceNotFound",
             f"There are no mesh resources with the arn {resource_arn}.",
         )
+
+
+class MeshOwnerDoesNotMatchError(MeshError):
+    def __init__(self, mesh_name: str, mesh_owner: str) -> None:
+        super().__init__(
+            "MeshOwnerDoesNotMatch",
+            f"The owner of the mesh {mesh_name} does not match the owner name provided: {mesh_owner}.",
+        )
+
+
+class VirtualRouterNameAlreadyTakenError(MeshError):
+    def __init__(self, mesh_name: str, virtual_router_name: str) -> None:
+        super().__init__(
+            "VirtualRouterNameAlreadyTaken",
+            f"There is already a virtual router named {virtual_router_name} associated with the mesh {mesh_name}.",
+        )
+
+
+class VirtualRouterNotFoundError(MeshError):
+    def __init__(self, mesh_name: str, virtual_router_name: str) -> None:
+        super().__init__(
+            "VirtualRouterNotFound",
+            f"The mesh {mesh_name} does not have a virtual router named {virtual_router_name}.",
+        )
+
+
+class RouteNotFoundError(MeshError):
+    def __init__(
+        self, mesh_name: str, virtual_router_name: str, route_name: str
+    ) -> None:
+        super().__init__(
+            "RouteNotFound",
+            f"There is no route named {route_name} associated with router {virtual_router_name} in mesh {mesh_name}.",
+        )
+
+
+class RouteNameAlreadyTakenError(MeshError):
+    def __init__(
+        self, mesh_name: str, virtual_router_name: str, route_name: str
+    ) -> None:
+        super().__init__(
+            "RouteNameAlreadyTaken",
+            f"There is already a route named {route_name} associated with router {virtual_router_name} in mesh {mesh_name}.",
+        )
diff --git a/moto/appmesh/models.py b/moto/appmesh/models.py
index 76cf52b3f..615804f19 100644
--- a/moto/appmesh/models.py
+++ b/moto/appmesh/models.py
@@ -1,64 +1,32 @@
 """AppMeshBackend class with methods for supported APIs."""
 
-from dataclasses import dataclass
-from datetime import datetime
 from typing import Any, Dict, List, Literal, Optional
-from uuid import uuid4
 
-from moto.appmesh.exceptions import MeshNotFoundError, ResourceNotFoundError
+from moto.appmesh.dataclasses.mesh import (
+    Mesh,
+    MeshSpec,
+)
+from moto.appmesh.dataclasses.route import Route, RouteMetadata, RouteSpec
+from moto.appmesh.dataclasses.shared import Metadata
+from moto.appmesh.dataclasses.virtual_router import (
+    PortMapping,
+    VirtualRouter,
+    VirtualRouterSpec,
+)
+from moto.appmesh.exceptions import (
+    MeshNotFoundError,
+    ResourceNotFoundError,
+)
+from moto.appmesh.utils import (
+    check_route_availability,
+    check_route_validity,
+    check_router_availability,
+    check_router_validity,
+    validate_mesh,
+)
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.utilities.paginator import paginate
 
-
-@dataclass
-class Metadata:
-    arn: str
-    mesh_owner: str
-    resource_owner: str
-    created_at: datetime = datetime.now()
-    last_updated_at: datetime = datetime.now()
-    uid: str = uuid4().hex
-    version: int = 1
-
-
-@dataclass
-class Spec:
-    egress_filter: Dict[Literal["type"], Optional[str]]
-    service_discovery: Dict[Literal["ip_preference"], Optional[str]]
-
-
-@dataclass
-class Mesh:
-    mesh_name: str
-    metadata: Metadata
-    spec: Spec
-    status: Dict[Literal["status"], str]
-    tags: List[Dict[str, str]]
-
-    def to_dict(self) -> Dict[str, Any]:  # type ignore[misc]
-        return {
-            "meshName": self.mesh_name,
-            "metadata": {
-                "arn": self.metadata.arn,
-                "createdAt": self.metadata.created_at.strftime("%d/%m/%Y, %H:%M:%S"),
-                "lastUpdatedAt": self.metadata.last_updated_at.strftime(
-                    "%d/%m/%Y, %H:%M:%S"
-                ),
-                "meshOwner": self.metadata.mesh_owner,
-                "resourceOwner": self.metadata.resource_owner,
-                "uid": self.metadata.uid,
-                "version": self.metadata.version,
-            },
-            "spec": {
-                "egressFilter": self.spec.egress_filter,
-                "serviceDiscovery": {
-                    "ipPreference": self.spec.service_discovery.get("ip_preference")
-                },
-            },
-            "status": self.status,
-        }
-
-
 PAGINATION_MODEL = {
     "list_meshes": {
         "input_token": "next_token",
@@ -72,6 +40,18 @@ PAGINATION_MODEL = {
         "limit_default": 100,
         "unique_attribute": ["key", "value"],
     },
+    "list_virtual_routers": {
+        "input_token": "next_token",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": "virtualRouterName",
+    },
+    "list_routes": {
+        "input_token": "next_token",
+        "limit_key": "limit",
+        "limit_default": 100,
+        "unique_attribute": ["routeName"],
+    },
 }
 
 
@@ -102,7 +82,7 @@ class AppMeshBackend(BaseBackend):
             mesh_owner=user_id,
             resource_owner=user_id,
         )
-        spec = Spec(
+        spec = MeshSpec(
             egress_filter={"type": egress_filter_type},
             service_discovery={"ip_preference": ip_preference},
         )
@@ -124,7 +104,7 @@ class AppMeshBackend(BaseBackend):
         ip_preference: Optional[str],
     ) -> Mesh:
         if mesh_name not in self.meshes:
-            raise MeshNotFoundError(mesh_name)
+            raise MeshNotFoundError(mesh_name=mesh_name)
         updated = False
         if egress_filter_type is not None:
             self.meshes[mesh_name].spec.egress_filter["type"] = egress_filter_type
@@ -137,18 +117,17 @@ class AppMeshBackend(BaseBackend):
             updated = True
 
         if updated:
-            self.meshes[mesh_name].metadata.last_updated_at = datetime.now()
+            self.meshes[mesh_name].metadata.update_timestamp()
             self.meshes[mesh_name].metadata.version += 1
         return self.meshes[mesh_name]
 
     def describe_mesh(self, mesh_name: str, mesh_owner: Optional[str]) -> Mesh:
-        if mesh_name not in self.meshes:
-            raise MeshNotFoundError(mesh_name)
+        validate_mesh(meshes=self.meshes, mesh_name=mesh_name, mesh_owner=mesh_owner)
         return self.meshes[mesh_name]
 
     def delete_mesh(self, mesh_name: str) -> Mesh:
         if mesh_name not in self.meshes:
-            raise MeshNotFoundError(mesh_name)
+            raise MeshNotFoundError(mesh_name=mesh_name)
         self.meshes[mesh_name].status["status"] = "DELETED"
         mesh = self.meshes[mesh_name]
         del self.meshes[mesh_name]
@@ -187,5 +166,248 @@ class AppMeshBackend(BaseBackend):
             mesh.tags.extend(tags)
         return
 
+    def describe_virtual_router(
+        self, mesh_name: str, mesh_owner: Optional[str], virtual_router_name: str
+    ) -> VirtualRouter:
+        check_router_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        return self.meshes[mesh_name].virtual_routers[virtual_router_name]
+
+    def create_virtual_router(
+        self,
+        client_token: str,
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        port_mappings: List[PortMapping],
+        tags: List[Dict[str, str]],
+        virtual_router_name: str,
+    ) -> VirtualRouter:
+        check_router_availability(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            virtual_router_name=virtual_router_name,
+            mesh_owner=mesh_owner,
+        )
+        owner = mesh_owner or self.meshes[mesh_name].metadata.mesh_owner
+        metadata = Metadata(
+            mesh_owner=owner,
+            resource_owner=owner,
+            arn=f"arn:aws:appmesh:{self.region_name}:{self.account_id}:mesh/{mesh_name}/virtualRouter/{virtual_router_name}",
+        )
+        listeners: List[Dict[Literal["port_mapping"], PortMapping]] = [
+            {"port_mapping": port_mapping} for port_mapping in port_mappings
+        ]
+        spec = VirtualRouterSpec(listeners=listeners)
+        virtual_router = VirtualRouter(
+            virtual_router_name=virtual_router_name,
+            mesh_name=mesh_name,
+            metadata=metadata,
+            status={"status": "ACTIVE"},
+            spec=spec,
+            tags=tags,
+        )
+        self.meshes[mesh_name].virtual_routers[virtual_router_name] = virtual_router
+        return virtual_router
+
+    def update_virtual_router(
+        self,
+        client_token: str,
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        port_mappings: List[PortMapping],
+        virtual_router_name: str,
+    ) -> VirtualRouter:
+        check_router_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        listeners: List[Dict[Literal["port_mapping"], PortMapping]] = [
+            {"port_mapping": port_mapping} for port_mapping in port_mappings
+        ]
+        spec = VirtualRouterSpec(listeners=listeners)
+        virtual_router = self.meshes[mesh_name].virtual_routers[virtual_router_name]
+        virtual_router.spec = spec
+        virtual_router.metadata.update_timestamp()
+        virtual_router.metadata.version += 1
+        return virtual_router
+
+    def delete_virtual_router(
+        self, mesh_name: str, mesh_owner: Optional[str], virtual_router_name: str
+    ) -> VirtualRouter:
+        check_router_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        mesh = self.meshes[mesh_name]
+        mesh.virtual_routers[virtual_router_name].status["status"] = "DELETED"
+        virtual_router = mesh.virtual_routers[virtual_router_name]
+        del mesh.virtual_routers[virtual_router_name]
+        return virtual_router
+
+    @paginate(pagination_model=PAGINATION_MODEL)  # type: ignore
+    def list_virtual_routers(
+        self, limit: int, mesh_name: str, mesh_owner: Optional[str], next_token: str
+    ):
+        validate_mesh(meshes=self.meshes, mesh_name=mesh_name, mesh_owner=mesh_owner)
+        return [
+            {
+                "arn": virtual_router.metadata.arn,
+                "createdAt": virtual_router.metadata.created_at.strftime(
+                    "%d/%m/%Y, %H:%M:%S"
+                ),
+                "lastUpdatedAt": virtual_router.metadata.last_updated_at.strftime(
+                    "%d/%m/%Y, %H:%M:%S"
+                ),
+                "meshName": virtual_router.mesh_name,
+                "meshOwner": virtual_router.metadata.mesh_owner,
+                "resourceOwner": virtual_router.metadata.resource_owner,
+                "version": virtual_router.metadata.version,
+                "virtualRouterName": virtual_router.virtual_router_name,
+            }
+            for virtual_router in self.meshes[mesh_name].virtual_routers.values()
+        ]
+
+    def create_route(
+        self,
+        client_token: Optional[str],
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        route_name: str,
+        spec: RouteSpec,
+        tags: Optional[List[Dict[str, str]]],
+        virtual_router_name: str,
+    ) -> Route:
+        check_route_availability(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            route_name=route_name,
+            virtual_router_name=virtual_router_name,
+        )
+        owner = mesh_owner or self.meshes[mesh_name].metadata.mesh_owner
+        metadata = RouteMetadata(
+            arn=f"arn:aws:appmesh:{self.region_name}:{self.account_id}:mesh/{mesh_name}/virtualRouter/{virtual_router_name}/route/{route_name}",
+            mesh_name=mesh_name,
+            mesh_owner=owner,
+            resource_owner=owner,
+            route_name=route_name,
+            virtual_router_name=virtual_router_name,
+        )
+        route = Route(
+            mesh_name=mesh_name,
+            mesh_owner=owner,
+            metadata=metadata,
+            route_name=route_name,
+            spec=spec,
+            tags=tags,
+            virtual_router_name=virtual_router_name,
+        )
+        self.meshes[mesh_name].virtual_routers[virtual_router_name].routes[
+            route_name
+        ] = route
+        return route
+
+    def describe_route(
+        self,
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        route_name: str,
+        virtual_router_name: str,
+    ) -> Route:
+        check_route_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+            route_name=route_name,
+        )
+        return (
+            self.meshes[mesh_name]
+            .virtual_routers[virtual_router_name]
+            .routes[route_name]
+        )
+
+    def update_route(
+        self,
+        client_token: Optional[str],
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        route_name: str,
+        spec: RouteSpec,
+        virtual_router_name: str,
+    ) -> Route:
+        check_route_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+            route_name=route_name,
+        )
+        route = (
+            self.meshes[mesh_name]
+            .virtual_routers[virtual_router_name]
+            .routes[route_name]
+        )
+        route.spec = spec
+        route.metadata.version += 1
+        route.metadata.update_timestamp()
+        return route
+
+    def delete_route(
+        self,
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        route_name: str,
+        virtual_router_name: str,
+    ) -> Route:
+        check_route_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+            route_name=route_name,
+        )
+        route = (
+            self.meshes[mesh_name]
+            .virtual_routers[virtual_router_name]
+            .routes[route_name]
+        )
+        route.status["status"] = "DELETED"
+        del (
+            self.meshes[mesh_name]
+            .virtual_routers[virtual_router_name]
+            .routes[route_name]
+        )
+        return route
+
+    @paginate(pagination_model=PAGINATION_MODEL)  # type: ignore
+    def list_routes(
+        self,
+        limit: Optional[int],
+        mesh_name: str,
+        mesh_owner: Optional[str],
+        next_token: Optional[str],
+        virtual_router_name: str,
+    ) -> List[Dict[str, Any]]:
+        check_router_validity(
+            meshes=self.meshes,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        virtual_router = self.meshes[mesh_name].virtual_routers[virtual_router_name]
+        return [
+            route.metadata.formatted_for_list_api()
+            for route in virtual_router.routes.values()
+        ]
+
 
 appmesh_backends = BackendDict(AppMeshBackend, "appmesh")
diff --git a/moto/appmesh/responses.py b/moto/appmesh/responses.py
index aa2dd06a4..b5001695a 100644
--- a/moto/appmesh/responses.py
+++ b/moto/appmesh/responses.py
@@ -2,6 +2,10 @@
 
 import json
 
+from moto.appmesh.utils import (
+    build_spec,
+    port_mappings_from_router_spec,
+)
 from moto.core.responses import BaseResponse
 
 from .models import AppMeshBackend, appmesh_backends
@@ -61,9 +65,7 @@ class AppMeshResponse(BaseResponse):
 
     def delete_mesh(self) -> str:
         mesh_name = self._get_param("meshName")
-        mesh = self.appmesh_backend.delete_mesh(
-            mesh_name=mesh_name,
-        )
+        mesh = self.appmesh_backend.delete_mesh(mesh_name=mesh_name)
         return json.dumps(mesh.to_dict())
 
     def list_meshes(self) -> str:
@@ -97,3 +99,151 @@ class AppMeshResponse(BaseResponse):
             tags=tags,
         )
         return json.dumps(dict())
+
+    def describe_virtual_router(self) -> str:
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        virtual_router_name = self._get_param("virtualRouterName")
+        virtual_router = self.appmesh_backend.describe_virtual_router(
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(virtual_router.to_dict())
+
+    def create_virtual_router(self) -> str:
+        params = json.loads(self.body)
+        client_token = params.get("clientToken")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        port_mappings = port_mappings_from_router_spec(params.get("spec"))
+        tags = params.get("tags")
+        virtual_router_name = params.get("virtualRouterName")
+        virtual_router = self.appmesh_backend.create_virtual_router(
+            client_token=client_token,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            port_mappings=port_mappings,
+            tags=tags,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(virtual_router.to_dict())
+
+    def update_virtual_router(self) -> str:
+        params = json.loads(self.body)
+        client_token = params.get("clientToken")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        port_mappings = port_mappings_from_router_spec(params.get("spec"))
+        virtual_router_name = self._get_param("virtualRouterName")
+        virtual_router = self.appmesh_backend.update_virtual_router(
+            client_token=client_token,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            port_mappings=port_mappings,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(virtual_router.to_dict())
+
+    def delete_virtual_router(self) -> str:
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        virtual_router_name = self._get_param("virtualRouterName")
+        virtual_router = self.appmesh_backend.delete_virtual_router(
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(virtual_router.to_dict())
+
+    def list_virtual_routers(self) -> str:
+        limit = self._get_param("limit")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        next_token = self._get_param("nextToken")
+        virtual_routers, next_token = self.appmesh_backend.list_virtual_routers(
+            limit=limit,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            next_token=next_token,
+        )
+        return json.dumps(dict(nextToken=next_token, virtualRouters=virtual_routers))
+
+    def create_route(self) -> str:
+        params = json.loads(self.body)
+        client_token = params.get("clientToken")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        route_name = self._get_param("routeName")
+        tags = params.get("tags")
+        virtual_router_name = self._get_param("virtualRouterName")
+        spec = build_spec(params.get("spec") or {})
+        route = self.appmesh_backend.create_route(
+            client_token=client_token,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            route_name=route_name,
+            spec=spec,
+            tags=tags,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(route.to_dict())
+
+    def describe_route(self) -> str:
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        route_name = self._get_param("routeName")
+        virtual_router_name = self._get_param("virtualRouterName")
+        route = self.appmesh_backend.describe_route(
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            route_name=route_name,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(route.to_dict())
+
+    def update_route(self) -> str:
+        params = json.loads(self.body)
+        client_token = params.get("clientToken")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        route_name = self._get_param("routeName")
+        virtual_router_name = self._get_param("virtualRouterName")
+        spec = build_spec(params.get("spec") or {})
+        route = self.appmesh_backend.update_route(
+            client_token=client_token,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            route_name=route_name,
+            spec=spec,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(route.to_dict())
+
+    def delete_route(self) -> str:
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        route_name = self._get_param("routeName")
+        virtual_router_name = self._get_param("virtualRouterName")
+        route = self.appmesh_backend.delete_route(
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            route_name=route_name,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(route.to_dict())
+
+    def list_routes(self) -> str:
+        limit = self._get_param("limit")
+        mesh_name = self._get_param("meshName")
+        mesh_owner = self._get_param("meshOwner")
+        next_token = self._get_param("nextToken")
+        virtual_router_name = self._get_param("virtualRouterName")
+        routes, next_token = self.appmesh_backend.list_routes(
+            limit=limit,
+            mesh_name=mesh_name,
+            mesh_owner=mesh_owner,
+            next_token=next_token,
+            virtual_router_name=virtual_router_name,
+        )
+        return json.dumps(dict(nextToken=next_token, routes=routes))
diff --git a/moto/appmesh/urls.py b/moto/appmesh/urls.py
index 6d21691ec..01979176b 100644
--- a/moto/appmesh/urls.py
+++ b/moto/appmesh/urls.py
@@ -11,4 +11,8 @@ url_paths = {
     "{0}/v20190125/meshes/(?P<meshName>[^/]+)$": AppMeshResponse.dispatch,
     "{0}/v20190125/tags$": AppMeshResponse.dispatch,
     "{0}/v20190125/tag$": AppMeshResponse.dispatch,
+    "{0}/v20190125/meshes/(?P<meshName>.*)/virtualRouters/(?P<virtualRouterName>[^/]+)$": AppMeshResponse.dispatch,
+    "{0}/v20190125/meshes/(?P<meshName>.*)/virtualRouters$": AppMeshResponse.dispatch,
+    "{0}/v20190125/meshes/(?P<meshName>.*)/virtualRouter/(?P<virtualRouterName>.*)/routes$": AppMeshResponse.dispatch,
+    "{0}/v20190125/meshes/(?P<meshName>.*)/virtualRouter/(?P<virtualRouterName>.*)/routes/(?P<routeName>[^/]+)$": AppMeshResponse.dispatch,
 }
diff --git a/moto/appmesh/utils.py b/moto/appmesh/utils.py
new file mode 100644
index 000000000..6c788c673
--- /dev/null
+++ b/moto/appmesh/utils.py
@@ -0,0 +1,322 @@
+from typing import Any, Dict, List, Optional
+
+from moto.appmesh.dataclasses.mesh import Mesh
+from moto.appmesh.dataclasses.route import (
+    GrcpRouteRetryPolicy,
+    GrpcMetadatum,
+    GrpcRoute,
+    GrpcRouteMatch,
+    HttpRoute,
+    HttpRouteMatch,
+    HttpRouteRetryPolicy,
+    Match,
+    QueryParameterMatch,
+    Range,
+    RouteAction,
+    RouteActionWeightedTarget,
+    RouteMatchPath,
+    RouteMatchQueryParameter,
+    RouteSpec,
+    TCPRoute,
+    TCPRouteMatch,
+    Timeout,
+    TimeValue,
+)
+from moto.appmesh.dataclasses.virtual_router import PortMapping
+from moto.appmesh.exceptions import (
+    MeshNotFoundError,
+    MeshOwnerDoesNotMatchError,
+    RouteNameAlreadyTakenError,
+    RouteNotFoundError,
+    VirtualRouterNameAlreadyTakenError,
+    VirtualRouterNotFoundError,
+)
+
+
+def port_mappings_from_router_spec(spec: Any) -> List[PortMapping]:  # type: ignore[misc]
+    return [
+        PortMapping(
+            port=(listener.get("portMapping") or {}).get("port"),
+            protocol=(listener.get("portMapping") or {}).get("protocol"),
+        )
+        for listener in ((spec or {}).get("listeners") or [])
+    ]
+
+
+def get_action_from_route(route: Any) -> RouteAction:  # type: ignore[misc]
+    weighted_targets = [
+        RouteActionWeightedTarget(
+            port=target.get("port"),
+            virtual_node=target.get("virtualNode"),
+            weight=target.get("weight"),
+        )
+        for target in (route.get("action") or {}).get("weightedTargets") or []
+    ]
+    return RouteAction(weighted_targets=weighted_targets)
+
+
+def get_route_match_metadata(metadata: List[Any]) -> List[GrpcMetadatum]:  # type: ignore[misc]
+    output = []
+    for _metadatum in metadata:
+        _match = _metadatum.get("match")
+        match = None
+        if _match is not None:
+            _range = _match.get("range")
+            range = None
+            if _range is not None:
+                range = Range(start=_range.get("start"), end=_range.get("end"))
+            match = Match(
+                exact=_match.get("exact"),
+                prefix=_match.get("prefix"),
+                range=range,
+                regex=_match.get("regex"),
+                suffix=_match.get("suffix"),
+            )
+        output.append(
+            GrpcMetadatum(
+                invert=_metadatum.get("invert"),
+                match=match,
+                name=_metadatum.get("name"),
+            )
+        )
+    return output
+
+
+def get_grpc_route_match(route: Any) -> GrpcRouteMatch:  # type: ignore[misc]
+    _route_match = route.get("match")
+    metadata = None
+    if _route_match is not None:
+        metadata = get_route_match_metadata(_route_match.get("metadata") or [])
+    return GrpcRouteMatch(
+        metadata=metadata,
+        method_name=_route_match.get("methodName"),
+        port=_route_match.get("port"),
+        service_name=_route_match.get("serviceName"),
+    )
+
+
+def get_http_match_from_route(route: Any) -> HttpRouteMatch:  # type: ignore[misc]
+    _route_match = route.get("match") or {}
+    headers, path, query_parameters = None, None, None
+    if _route_match is not None:
+        headers = get_route_match_metadata(_route_match.get("headers") or [])
+        _path = _route_match.get("path")
+        if _path is not None:
+            path = RouteMatchPath(exact=_path.get("exact"), regex=_path.get("regex"))
+        _query_parameters = _route_match.get("queryParameters")
+        if _query_parameters is not None:
+            query_parameters = []
+            for _param in _query_parameters:
+                _match = _param.get("match")
+                match = None
+                if _match is not None:
+                    match = QueryParameterMatch(exact=_match.get("exact"))
+                query_parameters.append(
+                    RouteMatchQueryParameter(name=_param.get("name"), match=match)
+                )
+    return HttpRouteMatch(
+        headers=headers,
+        method=(_route_match or {}).get("method"),
+        path=path,
+        port=(_route_match or {}).get("port"),
+        prefix=(_route_match or {}).get("prefix"),
+        query_parameters=query_parameters,
+        scheme=(_route_match or {}).get("scheme"),
+    )
+
+
+def get_http_retry_policy_from_route(route: Any) -> Optional[HttpRouteRetryPolicy]:  # type: ignore[misc]
+    _retry_policy = route.get("retryPolicy")
+    retry_policy = None
+    if _retry_policy is not None:
+        _per_retry_timeout = _retry_policy.get("perRetryTimeout")
+        per_retry_timeout = TimeValue(
+            unit=_per_retry_timeout.get("unit"), value=_per_retry_timeout.get("value")
+        )
+        retry_policy = HttpRouteRetryPolicy(
+            max_retries=_retry_policy.get("maxRetries"),
+            http_retry_events=_retry_policy.get("httpRetryEvents"),
+            per_retry_timeout=per_retry_timeout,
+            tcp_retry_events=_retry_policy.get("tcpRetryEvents"),
+        )
+    return retry_policy
+
+
+def get_timeout_from_route(route: Any) -> Optional[Timeout]:  # type: ignore[misc]
+    _timeout = route.get("timeout") or {}
+    idle, per_request = None, None
+    _idle = _timeout.get("idle")
+    if _idle is not None:
+        idle = TimeValue(unit=_idle.get("unit"), value=_idle.get("value"))
+    _per_request = _timeout.get("perRequest")
+    if _per_request is not None:
+        per_request = TimeValue(
+            unit=_per_request.get("unit"), value=_per_request.get("value")
+        )
+    return (
+        Timeout(idle=idle, per_request=per_request)
+        if idle is not None or per_request is not None
+        else None
+    )
+
+
+def validate_mesh(
+    meshes: Dict[str, Mesh], mesh_name: str, mesh_owner: Optional[str]
+) -> None:
+    if mesh_name not in meshes:
+        raise MeshNotFoundError(mesh_name=mesh_name)
+    if mesh_owner is not None and meshes[mesh_name].metadata.mesh_owner != mesh_owner:
+        raise MeshOwnerDoesNotMatchError(mesh_name, mesh_owner)
+
+
+def check_router_availability(
+    meshes: Dict[str, Mesh],
+    mesh_name: str,
+    mesh_owner: Optional[str],
+    virtual_router_name: str,
+) -> None:
+    validate_mesh(meshes=meshes, mesh_name=mesh_name, mesh_owner=mesh_owner)
+    if virtual_router_name in meshes[mesh_name].virtual_routers:
+        raise VirtualRouterNameAlreadyTakenError(
+            virtual_router_name=virtual_router_name, mesh_name=mesh_name
+        )
+    return
+
+
+def check_router_validity(
+    meshes: Dict[str, Mesh],
+    mesh_name: str,
+    mesh_owner: Optional[str],
+    virtual_router_name: str,
+) -> None:
+    validate_mesh(meshes=meshes, mesh_name=mesh_name, mesh_owner=mesh_owner)
+    if virtual_router_name not in meshes[mesh_name].virtual_routers:
+        raise VirtualRouterNotFoundError(
+            virtual_router_name=virtual_router_name, mesh_name=mesh_name
+        )
+    return
+
+
+def check_route_validity(
+    meshes: Dict[str, Mesh],
+    mesh_name: str,
+    mesh_owner: Optional[str],
+    virtual_router_name: str,
+    route_name: str,
+) -> None:
+    check_router_validity(
+        meshes=meshes,
+        mesh_name=mesh_name,
+        mesh_owner=mesh_owner,
+        virtual_router_name=virtual_router_name,
+    )
+    if route_name not in meshes[mesh_name].virtual_routers[virtual_router_name].routes:
+        raise RouteNotFoundError(
+            mesh_name=mesh_name,
+            virtual_router_name=virtual_router_name,
+            route_name=route_name,
+        )
+    return
+
+
+def check_route_availability(
+    meshes: Dict[str, Mesh],
+    mesh_name: str,
+    mesh_owner: Optional[str],
+    virtual_router_name: str,
+    route_name: str,
+) -> None:
+    check_router_validity(
+        meshes=meshes,
+        mesh_name=mesh_name,
+        mesh_owner=mesh_owner,
+        virtual_router_name=virtual_router_name,
+    )
+    if route_name in meshes[mesh_name].virtual_routers[virtual_router_name].routes:
+        raise RouteNameAlreadyTakenError(
+            mesh_name=mesh_name,
+            virtual_router_name=virtual_router_name,
+            route_name=route_name,
+        )
+    return
+
+
+def build_spec(spec: Dict[str, Any]) -> RouteSpec:  # type: ignore[misc]
+    _grpc_route = spec.get("grpcRoute")
+    _http_route = spec.get("httpRoute")
+    _http2_route = spec.get("http2Route")
+    _tcp_route = spec.get("tcpRoute")
+    grpc_route, http_route, http2_route, tcp_route = None, None, None, None
+    if _grpc_route is not None:
+        grpc_action = get_action_from_route(_grpc_route)
+        grpc_route_match = get_grpc_route_match(_grpc_route)
+
+        _retry_policy = _grpc_route.get("retryPolicy")
+        grpc_retry_policy = None
+        if _retry_policy is not None:
+            _per_retry_timeout = _retry_policy.get("perRetryTimeout")
+            per_retry_timeout = TimeValue(
+                unit=_per_retry_timeout.get("unit"),
+                value=_per_retry_timeout.get("value"),
+            )
+            grpc_retry_policy = GrcpRouteRetryPolicy(
+                grpc_retry_events=_retry_policy.get("grpcRetryEvents"),
+                http_retry_events=_retry_policy.get("httpRetryEvents"),
+                max_retries=_retry_policy.get("maxRetries"),
+                per_retry_timeout=per_retry_timeout,
+                tcp_retry_events=_retry_policy.get("tcpRetryEvents"),
+            )
+
+        grpc_timeout = get_timeout_from_route(_grpc_route)
+
+        grpc_route = GrpcRoute(
+            action=grpc_action,
+            match=grpc_route_match,
+            retry_policy=grpc_retry_policy,
+            timeout=grpc_timeout,
+        )
+
+    if _http_route is not None:
+        http_action = get_action_from_route(_http_route)
+        http_match = get_http_match_from_route(_http_route)
+        http_retry_policy = get_http_retry_policy_from_route(_http_route)
+        http_timeout = get_timeout_from_route(_http_route)
+
+        http_route = HttpRoute(
+            action=http_action,
+            match=http_match,
+            retry_policy=http_retry_policy,
+            timeout=http_timeout,
+        )
+
+    if _http2_route is not None:
+        http2_action = get_action_from_route(_http2_route)
+        http2_match = get_http_match_from_route(_http2_route)
+        http2_retry_policy = get_http_retry_policy_from_route(_http2_route)
+        http2_timeout = get_timeout_from_route(_http2_route)
+
+        http2_route = HttpRoute(
+            action=http2_action,
+            match=http2_match,
+            retry_policy=http2_retry_policy,
+            timeout=http2_timeout,
+        )
+
+    if _tcp_route is not None:
+        tcp_action = get_action_from_route(_tcp_route)
+        tcp_timeout = get_timeout_from_route(_tcp_route)
+
+        _tcp_match = _tcp_route.get("match")
+        tcp_match = None
+        if _tcp_match is not None:
+            tcp_match = TCPRouteMatch(port=_tcp_match.get("port"))
+
+        tcp_route = TCPRoute(action=tcp_action, match=tcp_match, timeout=tcp_timeout)
+
+    return RouteSpec(
+        grpc_route=grpc_route,
+        http_route=http_route,
+        http2_route=http2_route,
+        priority=spec.get("priority"),
+        tcp_route=tcp_route,
+    )


Test patch:
diff --git a/tests/test_appmesh/data.py b/tests/test_appmesh/data.py
new file mode 100644
index 000000000..e7369a847
--- /dev/null
+++ b/tests/test_appmesh/data.py
@@ -0,0 +1,158 @@
+grpc_route_spec = {
+    "priority": 1,
+    "grpcRoute": {
+        "action": {
+            "weightedTargets": [
+                {"port": 8080, "virtualNode": "my-virtual-node", "weight": 50}
+            ]
+        },
+        "match": {
+            "metadata": [
+                {
+                    "invert": False,
+                    "match": {"exact": "example-value"},
+                    "name": "my-metadata-key",
+                }
+            ],
+            "methodName": "myMethod",
+            "port": 8080,
+            "serviceName": "myService",
+        },
+        "retryPolicy": {
+            "grpcRetryEvents": ["unavailable", "resource-exhausted"],
+            "httpRetryEvents": ["gateway-error"],
+            "maxRetries": 3,
+            "perRetryTimeout": {"unit": "ms", "value": 200},
+            "tcpRetryEvents": ["connection-error"],
+        },
+        "timeout": {
+            "idle": {"unit": "s", "value": 60},
+            "perRequest": {"unit": "s", "value": 5},
+        },
+    },
+}
+
+http_route_spec = {
+    "priority": 2,
+    "httpRoute": {
+        "action": {
+            "weightedTargets": [
+                {"port": 80, "virtualNode": "web-server-node", "weight": 100}
+            ]
+        },
+        "match": {
+            "headers": [
+                {
+                    "invert": True,
+                    "match": {"prefix": "Bearer "},
+                    "name": "Authorization",
+                }
+            ],
+            "method": "POST",
+            "path": {"exact": "/login"},
+            "port": 80,
+            "queryParameters": [
+                {"match": {"exact": "example-match"}, "name": "http-query-param"}
+            ],
+            "scheme": "http",
+        },
+        "retryPolicy": {
+            "httpRetryEvents": ["gateway-error", "client-error"],
+            "maxRetries": 0,
+            "perRetryTimeout": {"unit": "ms", "value": 0},
+            "tcpRetryEvents": ["connection-error"],
+        },
+        "timeout": {
+            "idle": {"unit": "s", "value": 15},
+            "perRequest": {"unit": "s", "value": 1},
+        },
+    },
+}
+
+http2_route_spec = {
+    "priority": 3,
+    "http2Route": {
+        "action": {
+            "weightedTargets": [
+                {"port": 80, "virtualNode": "web-server-node", "weight": 75}
+            ]
+        },
+        "match": {
+            "headers": [
+                {
+                    "invert": False,
+                    "match": {"exact": "application/json"},
+                    "name": "Content-Type",
+                }
+            ],
+            "method": "GET",
+            "path": {"exact": "/api/products"},
+            "port": 80,
+            "prefix": "/api",
+            "queryParameters": [
+                {"match": {"exact": "electronics"}, "name": "category"}
+            ],
+            "scheme": "https",
+        },
+        "retryPolicy": {
+            "httpRetryEvents": ["server-error"],
+            "maxRetries": 2,
+            "perRetryTimeout": {"unit": "ms", "value": 500},
+            "tcpRetryEvents": ["connection-error"],
+        },
+        "timeout": {
+            "idle": {"unit": "s", "value": 30},
+            "perRequest": {"unit": "s", "value": 2},
+        },
+    },
+}
+
+tcp_route_spec = {
+    "priority": 4,
+    "tcpRoute": {
+        "action": {
+            "weightedTargets": [
+                {"port": 22, "virtualNode": "ssh-server-node", "weight": 100}
+            ]
+        },
+        "match": {"port": 22},
+        "timeout": {"idle": {"unit": "s", "value": 600}},
+    },
+}
+
+modified_http_route_spec = {
+    "priority": 5,
+    "httpRoute": {
+        "action": {
+            "weightedTargets": [
+                {"port": 8080, "virtualNode": "api-server-node", "weight": 50}
+            ]
+        },
+        "match": {
+            "headers": [
+                {
+                    "invert": False,
+                    "match": {"prefix": "Token "},
+                    "name": "X-Auth-Token",
+                }
+            ],
+            "method": "GET",
+            "path": {"exact": "/profile"},
+            "port": 443,
+            "queryParameters": [
+                {"match": {"exact": "modified-match"}, "name": "filter-param"}
+            ],
+            "scheme": "https",
+        },
+        "retryPolicy": {
+            "httpRetryEvents": ["server-error"],
+            "maxRetries": 3,
+            "perRetryTimeout": {"unit": "s", "value": 2},
+            "tcpRetryEvents": ["connection-reset"],
+        },
+        "timeout": {
+            "idle": {"unit": "m", "value": 5},
+            "perRequest": {"unit": "ms", "value": 500},
+        },
+    },
+}
diff --git a/tests/test_appmesh/test_appmesh.py b/tests/test_appmesh/test_appmesh.py
index b715ba514..f7c5ea6c8 100644
--- a/tests/test_appmesh/test_appmesh.py
+++ b/tests/test_appmesh/test_appmesh.py
@@ -9,6 +9,14 @@ from botocore.exceptions import ClientError
 
 from moto import mock_aws
 
+from .data import (
+    grpc_route_spec,
+    http2_route_spec,
+    http_route_spec,
+    modified_http_route_spec,
+    tcp_route_spec,
+)
+
 # 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
 
@@ -31,7 +39,6 @@ def test_create_list_update_describe_delete_mesh(client):
         tags=[{"key": "owner", "value": "moto"}],
     )
     mesh = connection.get("mesh")
-    assert mesh is not None
     assert mesh["meshName"] == "mesh1"
     assert mesh["spec"]["egressFilter"]["type"] == "DROP_ALL"
     assert mesh["spec"]["serviceDiscovery"]["ipPreference"] == "IPv4_ONLY"
@@ -53,7 +60,6 @@ def test_create_list_update_describe_delete_mesh(client):
         tags=[{"key": "owner", "value": "moto"}],
     )
     mesh = connection.get("mesh")
-    assert mesh is not None
     assert mesh["meshName"] == "mesh2"
     assert mesh["spec"]["egressFilter"]["type"] == "ALLOW_ALL"
     assert mesh["spec"]["serviceDiscovery"]["ipPreference"] == "IPv4_PREFERRED"
@@ -68,7 +74,6 @@ def test_create_list_update_describe_delete_mesh(client):
     # List all methods, expecting 2
     connection = client.list_meshes()
     meshes = connection.get("meshes")
-    assert meshes is not None
     assert isinstance(meshes, list)
     assert len(meshes) == 2
     names_counted = defaultdict(int)
@@ -88,7 +93,6 @@ def test_create_list_update_describe_delete_mesh(client):
         },
     )
     mesh = connection.get("mesh")
-    assert mesh is not None
     assert mesh["meshName"] == "mesh1"
     assert mesh["spec"]["egressFilter"]["type"] == "ALLOW_ALL"
     assert mesh["spec"]["serviceDiscovery"]["ipPreference"] == "IPv6_PREFERRED"
@@ -98,7 +102,6 @@ def test_create_list_update_describe_delete_mesh(client):
     # Describe mesh 1, should reflect changes
     connection = client.describe_mesh(meshName="mesh1")
     mesh = connection.get("mesh")
-    assert mesh is not None
     assert mesh["meshName"] == "mesh1"
     assert mesh["spec"]["egressFilter"]["type"] == "ALLOW_ALL"
     assert mesh["spec"]["serviceDiscovery"]["ipPreference"] == "IPv6_PREFERRED"
@@ -107,7 +110,6 @@ def test_create_list_update_describe_delete_mesh(client):
 
     connection = client.delete_mesh(meshName="mesh2")
     mesh = connection.get("mesh")
-    assert mesh is not None
     assert mesh["meshName"] == "mesh2"
     assert mesh["spec"]["egressFilter"]["type"] == "ALLOW_ALL"
     assert mesh["spec"]["serviceDiscovery"]["ipPreference"] == "IPv4_PREFERRED"
@@ -115,7 +117,6 @@ def test_create_list_update_describe_delete_mesh(client):
 
     connection = client.list_meshes()
     meshes = connection.get("meshes")
-    assert meshes is not None
     assert isinstance(meshes, list)
     assert len(meshes) == 1
     assert meshes[0]["meshName"] == "mesh1"
@@ -138,7 +139,6 @@ def test_tag_and_list_tags_for_resource(client):
         tags=[{"key": "owner", "value": "moto"}],
     )
     mesh = connection.get("mesh")
-    assert mesh is not None
     arn = mesh["metadata"]["arn"]
 
     client.tag_resource(
@@ -148,3 +148,493 @@ def test_tag_and_list_tags_for_resource(client):
     tags = connection["tags"]
     assert tags[0] == {"key": "owner", "value": "moto"}
     assert tags[1] == {"key": "organization", "value": "moto"}
+
+
+@mock_aws
+def test_create_describe_list_update_delete_virtual_router(client):
+    MESH_NAME = "mock_mesh"
+    connection = client.create_mesh(
+        meshName=MESH_NAME,
+        spec={
+            "egressFilter": {"type": "DROP_ALL"},
+            "serviceDiscovery": {"ipPreference": "IPv4_ONLY"},
+        },
+        tags=[{"key": "owner", "value": "moto"}],
+    )
+    assert "mesh" in connection
+    mesh = connection["mesh"]
+    assert "metadata" in mesh
+    mesh_owner = mesh["metadata"]["meshOwner"]
+
+    ROUTER_1 = "router1"
+    ROUTER_2 = "router2"
+
+    connection = client.create_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_1,
+        spec={"listeners": [{"portMapping": {"port": 80, "protocol": "http"}}]},
+        tags=[{"key": "router_traffic", "value": "http"}],
+    )
+    router1 = connection.get("virtualRouter")
+    assert router1["meshName"] == MESH_NAME
+    assert router1["metadata"]["meshOwner"] == mesh_owner
+    assert router1["metadata"]["version"] == 1
+    assert router1["spec"]["listeners"][0]["portMapping"]["port"] == 80
+    assert router1["spec"]["listeners"][0]["portMapping"]["protocol"] == "http"
+    assert router1["status"]["status"] == "ACTIVE"
+
+    connection = client.create_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_2,
+        spec={"listeners": [{"portMapping": {"port": 443, "protocol": "http2"}}]},
+        tags=[{"key": "router_traffic", "value": "https"}],
+    )
+    router2 = connection.get("virtualRouter")
+    assert router2["meshName"] == MESH_NAME
+    assert router2["metadata"]["meshOwner"] == mesh_owner
+    assert router2["metadata"]["version"] == 1
+    assert router2["spec"]["listeners"][0]["portMapping"]["port"] == 443
+    assert router2["spec"]["listeners"][0]["portMapping"]["protocol"] == "http2"
+    assert router2["status"]["status"] == "ACTIVE"
+    connection = client.list_virtual_routers(meshName=MESH_NAME, meshOwner=mesh_owner)
+    virtual_routers = connection.get("virtualRouters")
+    assert isinstance(virtual_routers, list)
+    assert len(virtual_routers) == 2
+    names_counted = defaultdict(int)
+    for virtual_router in virtual_routers:
+        router_name = virtual_router.get("virtualRouterName")
+        if router_name:
+            names_counted[router_name] += 1
+    assert names_counted[ROUTER_1] == 1
+    assert names_counted[ROUTER_2] == 1
+
+    connection = client.update_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_2,
+        spec={"listeners": [{"portMapping": {"port": 80, "protocol": "tcp"}}]},
+    )
+    updated_router2 = connection.get("virtualRouter")
+    assert updated_router2["virtualRouterName"] == ROUTER_2
+    assert updated_router2["meshName"] == MESH_NAME
+    assert updated_router2["metadata"]["meshOwner"] == mesh_owner
+    assert updated_router2["metadata"]["version"] == 2
+    assert updated_router2["spec"]["listeners"][0]["portMapping"]["port"] == 80
+    assert updated_router2["spec"]["listeners"][0]["portMapping"]["protocol"] == "tcp"
+    assert updated_router2["status"]["status"] == "ACTIVE"
+
+    connection = client.describe_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_2,
+    )
+    described_router2 = connection.get("virtualRouter")
+    assert described_router2["virtualRouterName"] == ROUTER_2
+    assert described_router2["meshName"] == MESH_NAME
+    assert described_router2["metadata"]["meshOwner"] == mesh_owner
+    assert described_router2["metadata"]["version"] == 2
+    assert described_router2["spec"]["listeners"][0]["portMapping"]["port"] == 80
+    assert described_router2["spec"]["listeners"][0]["portMapping"]["protocol"] == "tcp"
+    assert described_router2["status"]["status"] == "ACTIVE"
+
+    connection = client.delete_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_1,
+    )
+    deleted_router1 = connection.get("virtualRouter")
+    assert deleted_router1["virtualRouterName"] == ROUTER_1
+    assert deleted_router1["meshName"] == MESH_NAME
+    assert deleted_router1["metadata"]["meshOwner"] == mesh_owner
+    assert deleted_router1["metadata"]["version"] == 1
+    assert deleted_router1["spec"]["listeners"][0]["portMapping"]["port"] == 80
+    assert deleted_router1["spec"]["listeners"][0]["portMapping"]["protocol"] == "http"
+    assert deleted_router1["status"]["status"] == "DELETED"
+    with pytest.raises(ClientError) as e:
+        client.describe_virtual_router(
+            meshName=MESH_NAME,
+            meshOwner=mesh_owner,
+            virtualRouterName=ROUTER_1,
+        )
+    err = e.value.response["Error"]
+    assert err["Code"] == "VirtualRouterNotFound"
+    assert (
+        err["Message"]
+        == f"The mesh {MESH_NAME} does not have a virtual router named {ROUTER_1}."
+    )
+
+
+@mock_aws
+def test_create_describe_list_update_delete_route(client):
+    MESH_NAME = "mock_mesh"
+    ROUTER_NAME = "mock_virtual_router"
+
+    connection = client.create_mesh(
+        meshName=MESH_NAME,
+        spec={
+            "egressFilter": {"type": "DROP_ALL"},
+            "serviceDiscovery": {"ipPreference": "IPv4_ONLY"},
+        },
+        tags=[{"key": "owner", "value": "moto"}],
+    )
+    assert "mesh" in connection
+    mesh = connection["mesh"]
+    assert "metadata" in mesh
+    mesh_owner = mesh["metadata"]["meshOwner"]
+
+    connection = client.create_virtual_router(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        virtualRouterName=ROUTER_NAME,
+        spec={"listeners": [{"portMapping": {"port": 80, "protocol": "http"}}]},
+        tags=[{"key": "router_traffic", "value": "http"}],
+    )
+
+    router = connection.get("virtualRouter")
+    assert router["meshName"] == MESH_NAME
+    assert router["metadata"]["meshOwner"] == mesh_owner
+    assert router["virtualRouterName"] == ROUTER_NAME
+
+    ROUTE_1 = "route1"
+    ROUTE_2 = "route2"
+    ROUTE_3 = "route3"
+    ROUTE_4 = "route4"
+
+    connection = client.create_route(
+        meshOwner=mesh_owner,
+        meshName=MESH_NAME,
+        virtualRouterName=ROUTER_NAME,
+        routeName=ROUTE_1,
+        tags=[{"key": "license", "value": "apache"}],
+        spec=grpc_route_spec,
+    )
+
+    assert "route" in connection
+    route = connection["route"]
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 1
+    grpc_route = spec["grpcRoute"]
+
+    # action assertions
+    assert len(grpc_route["action"]["weightedTargets"]) == 1
+    weighted_target = grpc_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 8080
+    assert weighted_target["virtualNode"] == "my-virtual-node"
+    assert weighted_target["weight"] == 50
+
+    # match assertions
+    assert len(grpc_route["match"]["metadata"]) == 1
+    metadata = grpc_route["match"]["metadata"][0]
+    assert metadata["invert"] is False
+    assert metadata["match"]["exact"] == "example-value"
+    assert metadata["name"] == "my-metadata-key"
+    assert grpc_route["match"]["methodName"] == "myMethod"
+    assert grpc_route["match"]["port"] == 8080
+    assert grpc_route["match"]["serviceName"] == "myService"
+
+    # retryPolicy assertions
+    assert grpc_route["retryPolicy"]["grpcRetryEvents"] == [
+        "unavailable",
+        "resource-exhausted",
+    ]
+    assert grpc_route["retryPolicy"]["httpRetryEvents"] == ["gateway-error"]
+    assert grpc_route["retryPolicy"]["maxRetries"] == 3
+    assert grpc_route["retryPolicy"]["perRetryTimeout"]["unit"] == "ms"
+    assert grpc_route["retryPolicy"]["perRetryTimeout"]["value"] == 200
+    assert grpc_route["retryPolicy"]["tcpRetryEvents"] == ["connection-error"]
+
+    # timeout assertions
+    assert grpc_route["timeout"]["idle"]["unit"] == "s"
+    assert grpc_route["timeout"]["idle"]["value"] == 60
+    assert grpc_route["timeout"]["perRequest"]["unit"] == "s"
+    assert grpc_route["timeout"]["perRequest"]["value"] == 5
+
+    connection = client.create_route(
+        meshOwner=mesh_owner,
+        meshName=MESH_NAME,
+        virtualRouterName=ROUTER_NAME,
+        routeName=ROUTE_2,
+        tags=[{"key": "license", "value": "mit"}],
+        spec=http_route_spec,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 2
+    http_route = http_route_spec["httpRoute"]
+
+    # action assertions
+    assert len(http_route["action"]["weightedTargets"]) == 1
+    weighted_target = http_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 80
+    assert weighted_target["virtualNode"] == "web-server-node"
+    assert weighted_target["weight"] == 100
+
+    # match assertions
+    assert len(http_route["match"]["headers"]) == 1
+    header = http_route["match"]["headers"][0]
+    assert header["invert"] is True
+    assert header["match"]["prefix"] == "Bearer "
+    assert header["name"] == "Authorization"
+    assert http_route["match"]["method"] == "POST"
+    assert http_route["match"]["path"]["exact"] == "/login"
+    assert http_route["match"]["port"] == 80
+    assert (
+        http_route["match"]["queryParameters"][0]["match"]["exact"] == "example-match"
+    )
+    assert http_route["match"]["queryParameters"][0]["name"] == "http-query-param"
+    assert http_route["match"]["scheme"] == "http"
+
+    # retryPolicy assertions
+    assert http_route["retryPolicy"]["httpRetryEvents"] == [
+        "gateway-error",
+        "client-error",
+    ]
+    assert http_route["retryPolicy"]["maxRetries"] == 0
+    assert http_route["retryPolicy"]["perRetryTimeout"]["unit"] == "ms"
+    assert http_route["retryPolicy"]["perRetryTimeout"]["value"] == 0
+    assert http_route["retryPolicy"]["tcpRetryEvents"] == ["connection-error"]
+
+    # timeout assertions
+    assert http_route["timeout"]["idle"]["unit"] == "s"
+    assert http_route["timeout"]["idle"]["value"] == 15
+    assert http_route["timeout"]["perRequest"]["unit"] == "s"
+    assert http_route["timeout"]["perRequest"]["value"] == 1
+
+    connection = client.create_route(
+        meshOwner=mesh_owner,
+        meshName=MESH_NAME,
+        virtualRouterName=ROUTER_NAME,
+        routeName=ROUTE_3,
+        tags=[{"key": "license", "value": "mpl"}],
+        spec=http2_route_spec,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 3
+    http2_route = spec["http2Route"]
+
+    # action assertions
+    assert len(http2_route["action"]["weightedTargets"]) == 1
+    weighted_target = http2_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 80
+    assert weighted_target["virtualNode"] == "web-server-node"
+    assert weighted_target["weight"] == 75
+
+    # match assertions
+    assert len(http2_route["match"]["headers"]) == 1
+    header = http2_route["match"]["headers"][0]
+    assert header["invert"] is False
+    assert header["match"]["exact"] == "application/json"
+    assert header["name"] == "Content-Type"
+    assert http2_route["match"]["method"] == "GET"
+    assert http2_route["match"]["path"]["exact"] == "/api/products"
+    assert http2_route["match"]["port"] == 80
+    assert http2_route["match"]["prefix"] == "/api"
+    assert len(http2_route["match"]["queryParameters"]) == 1
+    query_param = http2_route["match"]["queryParameters"][0]
+    assert query_param["match"]["exact"] == "electronics"
+    assert query_param["name"] == "category"
+    assert http2_route["match"]["scheme"] == "https"
+
+    # retryPolicy assertions
+    assert http2_route["retryPolicy"]["httpRetryEvents"] == ["server-error"]
+    assert http2_route["retryPolicy"]["maxRetries"] == 2
+    assert http2_route["retryPolicy"]["perRetryTimeout"]["unit"] == "ms"
+    assert http2_route["retryPolicy"]["perRetryTimeout"]["value"] == 500
+    assert http2_route["retryPolicy"]["tcpRetryEvents"] == ["connection-error"]
+
+    # timeout assertions
+    assert http2_route["timeout"]["idle"]["unit"] == "s"
+    assert http2_route["timeout"]["idle"]["value"] == 30
+    assert http2_route["timeout"]["perRequest"]["unit"] == "s"
+    assert http2_route["timeout"]["perRequest"]["value"] == 2
+
+    connection = client.create_route(
+        meshOwner=mesh_owner,
+        meshName=MESH_NAME,
+        virtualRouterName=ROUTER_NAME,
+        routeName=ROUTE_4,
+        tags=[{"key": "license", "value": "bsd"}],
+        spec=tcp_route_spec,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 4
+    tcp_route = tcp_route_spec["tcpRoute"]
+
+    # action assertions
+    assert len(tcp_route["action"]["weightedTargets"]) == 1
+    weighted_target = tcp_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 22
+    assert weighted_target["virtualNode"] == "ssh-server-node"
+    assert weighted_target["weight"] == 100
+
+    # match assertions
+    assert tcp_route["match"]["port"] == 22
+
+    # timeout assertions
+    assert tcp_route["timeout"]["idle"]["unit"] == "s"
+    assert tcp_route["timeout"]["idle"]["value"] == 600
+
+    connection = client.list_routes(meshName=MESH_NAME, virtualRouterName=ROUTER_NAME)
+    routes = connection.get("routes")
+    assert isinstance(routes, list)
+    assert len(routes) == 4
+    names_counted = defaultdict(int)
+    for route in routes:
+        route_name = route.get("routeName")
+        if route_name:
+            names_counted[route_name] += 1
+    assert names_counted[ROUTE_1] == 1
+    assert names_counted[ROUTE_2] == 1
+    assert names_counted[ROUTE_3] == 1
+    assert names_counted[ROUTE_4] == 1
+
+    connection = client.update_route(
+        meshName=MESH_NAME,
+        routeName=ROUTE_2,
+        virtualRouterName=ROUTER_NAME,
+        spec=modified_http_route_spec,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert route["metadata"]["version"] == 2
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 5
+
+    modified_http_route = spec["httpRoute"]
+
+    # action assertions
+    assert len(modified_http_route["action"]["weightedTargets"]) == 1
+    weighted_target = modified_http_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 8080
+    assert weighted_target["virtualNode"] == "api-server-node"
+    assert weighted_target["weight"] == 50
+
+    # match assertions
+    assert len(modified_http_route["match"]["headers"]) == 1
+    header = modified_http_route["match"]["headers"][0]
+    assert header["invert"] is False
+    assert header["match"]["prefix"] == "Token "
+    assert header["name"] == "X-Auth-Token"
+    assert modified_http_route["match"]["method"] == "GET"
+    assert modified_http_route["match"]["path"]["exact"] == "/profile"
+    assert modified_http_route["match"]["port"] == 443
+    assert len(modified_http_route["match"]["queryParameters"]) == 1
+    query_param = modified_http_route["match"]["queryParameters"][0]
+    assert query_param["match"]["exact"] == "modified-match"
+    assert query_param["name"] == "filter-param"
+    assert modified_http_route["match"]["scheme"] == "https"
+
+    # retryPolicy assertions
+    assert modified_http_route["retryPolicy"]["httpRetryEvents"] == ["server-error"]
+    assert modified_http_route["retryPolicy"]["maxRetries"] == 3
+    assert modified_http_route["retryPolicy"]["perRetryTimeout"]["unit"] == "s"
+    assert modified_http_route["retryPolicy"]["perRetryTimeout"]["value"] == 2
+    assert modified_http_route["retryPolicy"]["tcpRetryEvents"] == ["connection-reset"]
+
+    # timeout assertions
+    assert modified_http_route["timeout"]["idle"]["unit"] == "m"
+    assert modified_http_route["timeout"]["idle"]["value"] == 5
+    assert modified_http_route["timeout"]["perRequest"]["unit"] == "ms"
+    assert modified_http_route["timeout"]["perRequest"]["value"] == 500
+    connection = client.describe_route(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        routeName=ROUTE_2,
+        virtualRouterName=ROUTER_NAME,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert route["metadata"]["version"] == 2
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 5
+
+    described_http_route = spec["httpRoute"]
+
+    # action assertions
+    assert len(described_http_route["action"]["weightedTargets"]) == 1
+    weighted_target = described_http_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 8080
+    assert weighted_target["virtualNode"] == "api-server-node"
+    assert weighted_target["weight"] == 50
+
+    # match assertions
+    assert len(described_http_route["match"]["headers"]) == 1
+    header = described_http_route["match"]["headers"][0]
+    assert header["invert"] is False
+    assert header["match"]["prefix"] == "Token "
+    assert header["name"] == "X-Auth-Token"
+    assert described_http_route["match"]["method"] == "GET"
+    assert described_http_route["match"]["path"]["exact"] == "/profile"
+    assert described_http_route["match"]["port"] == 443
+    assert len(described_http_route["match"]["queryParameters"]) == 1
+    query_param = described_http_route["match"]["queryParameters"][0]
+    assert query_param["match"]["exact"] == "modified-match"
+    assert query_param["name"] == "filter-param"
+    assert described_http_route["match"]["scheme"] == "https"
+
+    # retryPolicy assertions
+    assert described_http_route["retryPolicy"]["httpRetryEvents"] == ["server-error"]
+    assert described_http_route["retryPolicy"]["maxRetries"] == 3
+    assert described_http_route["retryPolicy"]["perRetryTimeout"]["unit"] == "s"
+    assert described_http_route["retryPolicy"]["perRetryTimeout"]["value"] == 2
+    assert described_http_route["retryPolicy"]["tcpRetryEvents"] == ["connection-reset"]
+
+    # timeout assertions
+    assert described_http_route["timeout"]["idle"]["unit"] == "m"
+    assert described_http_route["timeout"]["idle"]["value"] == 5
+    assert described_http_route["timeout"]["perRequest"]["unit"] == "ms"
+    assert described_http_route["timeout"]["perRequest"]["value"] == 500
+    connection = client.delete_route(
+        meshName=MESH_NAME,
+        meshOwner=mesh_owner,
+        routeName=ROUTE_4,
+        virtualRouterName=ROUTER_NAME,
+    )
+    assert "route" in connection
+    route = connection["route"]
+    assert route["status"]["status"] == "DELETED"
+    assert "spec" in route
+    spec = route["spec"]
+    assert spec["priority"] == 4
+    deleted_route = tcp_route_spec["tcpRoute"]
+
+    # action assertions
+    assert len(deleted_route["action"]["weightedTargets"]) == 1
+    weighted_target = deleted_route["action"]["weightedTargets"][0]
+    assert weighted_target["port"] == 22
+    assert weighted_target["virtualNode"] == "ssh-server-node"
+    assert weighted_target["weight"] == 100
+
+    # match assertions
+    assert deleted_route["match"]["port"] == 22
+
+    # timeout assertions
+    assert deleted_route["timeout"]["idle"]["unit"] == "s"
+    assert deleted_route["timeout"]["idle"]["value"] == 600
+
+    with pytest.raises(ClientError) as e:
+        client.describe_route(
+            meshName=MESH_NAME,
+            meshOwner=mesh_owner,
+            routeName=ROUTE_4,
+            virtualRouterName=ROUTER_NAME,
+        )
+    err = e.value.response["Error"]
+    assert err["Code"] == "RouteNotFound"
+    assert (
+        err["Message"]
+        == f"There is no route named {ROUTE_4} associated with router {ROUTER_NAME} in mesh {MESH_NAME}."
+    )
diff --git a/tests/test_route53/test_route53.py b/tests/test_route53/test_route53.py
index 9d96d2150..cc34720a6 100644
--- a/tests/test_route53/test_route53.py
+++ b/tests/test_route53/test_route53.py
@@ -257,7 +257,7 @@ def test_list_resource_record_set_invalid_start_record_name(invalid_char):
 
 
 @mock_aws
-@pytest.mark.parametrize("valid_char", ["*", ".", "\\t", "\ ", "国", "}", "\\ç"])
+@pytest.mark.parametrize("valid_char", ["*", ".", "\\t", "\\ ", "国", "}", "\\ç"])
 def test_list_resource_record_set_valid_characters(valid_char):
     """
     Validation that we can use odd characters as part of the domain name