1
from __future__ import annotations
10
from collections.abc import Awaitable, Callable, Collection, Generator, Sequence
11
from contextlib import AbstractAsyncContextManager, AbstractContextManager, asynccontextmanager
13
from re import Pattern
14
from typing import Any, TypeVar
16
from starlette._exception_handler import wrap_app_handling_exceptions
17
from starlette._utils import get_route_path, is_async_callable, parse_host_header
18
from starlette.concurrency import run_in_threadpool
19
from starlette.convertors import CONVERTOR_TYPES, Convertor
20
from starlette.datastructures import URL, Headers, URLPath
21
from starlette.exceptions import HTTPException, StarletteDeprecationWarning
22
from starlette.middleware import Middleware
23
from starlette.middleware.body_limit import RequestBodyLimitMiddleware
24
from starlette.requests import Request
25
from starlette.responses import PlainTextResponse, RedirectResponse, Response
26
from starlette.types import ASGIApp, Lifespan, Receive, Scope, Send
27
from starlette.websockets import WebSocket, WebSocketClose
30
class NoMatchFound(Exception):
32
Raised by `.url_for(name, **path_params)` and `.url_path_for(name, **path_params)`
33
if no matching route exists.
36
def __init__(self, name: str, path_params: dict[str, Any]) -> None:
37
params = ", ".join(list(path_params.keys()))
38
super().__init__(f'No route exists for name "{name}" and params "{params}".')
48
func: Callable[[Request], Awaitable[Response] | Response],
51
Takes a function or coroutine `func(request) -> response`,
52
and returns an ASGI application.
54
f: Callable[[Request], Awaitable[Response]] = (
55
func if is_async_callable(func) else functools.partial(run_in_threadpool, func) # type: ignore[assignment, call-arg]
58
async def app(scope: Scope, receive: Receive, send: Send) -> None:
59
request = Request(scope, receive, send)
61
async def app(scope: Scope, receive: Receive, send: Send) -> None:
62
169
response = await f(request)
63
2
104
await response(scope, receive, send)
65
273
await wrap_app_handling_exceptions(app, request)(scope, receive, send)
70
def websocket_session(
71
func: Callable[[WebSocket], Awaitable[None]],
74
Takes a coroutine `func(session)`, and returns an ASGI application.
76
# assert asyncio.iscoroutinefunction(func), "WebSocket endpoints must be async"
78
async def app(scope: Scope, receive: Receive, send: Send) -> None:
79
session = WebSocket(scope, receive=receive, send=send)
81
async def app(scope: Scope, receive: Receive, send: Send) -> None:
84
await wrap_app_handling_exceptions(app, session)(scope, receive, send)
89
def get_name(endpoint: Callable[..., Any]) -> str:
90
return getattr(endpoint, "__name__", endpoint.__class__.__name__)
95
param_convertors: dict[str, Convertor[Any]],
96
path_params: dict[str, str],
97
) -> tuple[str, dict[str, str]]:
98
for key, value in list(path_params.items()):
99
if "{" + key + "}" in path:
100
convertor = param_convertors[key]
101
value = convertor.to_string(value)
102
path = path.replace("{" + key + "}", value)
104
return path, path_params
107
# Match parameters in URL paths, eg. '{param}', and '{param:int}'
108
PARAM_REGEX = re.compile("{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?}")
113
) -> tuple[Pattern[str], str, dict[str, Convertor[Any]]]:
115
Given a path string, like: "/{username:str}",
116
or a host string, like: "{subdomain}.mydomain.org", return a three-tuple
117
of (regex, format, {param_name:convertor}).
119
regex: "/(?P<username>[^/]+)"
120
format: "/{username}"
121
convertors: {"username": StringConvertor()}
123
is_host = not path.startswith("/")
127
duplicated_params: set[str] = set()
130
param_convertors = {}
131
for match in PARAM_REGEX.finditer(path):
132
param_name, convertor_type = match.groups("str")
133
convertor_type = convertor_type.lstrip(":")
134
assert convertor_type in CONVERTOR_TYPES, f"Unknown path convertor '{convertor_type}'"
135
convertor = CONVERTOR_TYPES[convertor_type]
137
path_regex += re.escape(path[idx : match.start()])
138
path_regex += f"(?P<{param_name}>{convertor.regex})"
140
path_format += path[idx : match.start()]
141
path_format += "{%s}" % param_name
143
if param_name in param_convertors:
144
duplicated_params.add(param_name)
146
param_convertors[param_name] = convertor
150
if duplicated_params:
151
names = ", ".join(sorted(duplicated_params))
152
ending = "s" if len(duplicated_params) > 1 else ""
153
raise ValueError(f"Duplicated param name{ending} {names} at path {path}")
156
# Align with `Host.matches()` behavior, which ignores port.
157
hostname = path[idx:]
158
if not hostname.endswith("]"):
159
hostname = hostname.rsplit(":", 1)[0]
160
path_regex += re.escape(hostname) + "$"
162
path_regex += re.escape(path[idx:]) + "$"
164
path_format += path[idx:]
166
return re.compile(path_regex), path_format, param_convertors
170
def matches(self, scope: Scope) -> tuple[Match, Scope]:
171
raise NotImplementedError() # pragma: no cover
173
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
174
raise NotImplementedError() # pragma: no cover
176
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
177
raise NotImplementedError() # pragma: no cover
179
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
181
A route may be used in isolation as a stand-alone ASGI app.
182
This is a somewhat contrived case, as they'll almost always be used
183
within a Router, but could be useful for some tooling and minimal apps.
185
match, child_scope = self.matches(scope)
186
if match == Match.NONE:
187
if scope["type"] == "http":
188
response = PlainTextResponse("Not Found", status_code=404)
189
await response(scope, receive, send)
190
elif scope["type"] == "websocket": # pragma: no branch
191
websocket_close = WebSocketClose()
192
await websocket_close(scope, receive, send)
195
scope.update(child_scope)
196
await self.handle(scope, receive, send)
199
class Route(BaseRoute):
203
endpoint: Callable[..., Any],
205
methods: Collection[str] | None = None,
206
name: str | None = None,
207
include_in_schema: bool = True,
208
middleware: Sequence[Middleware] | None = None,
209
max_body_size: int | None = None,
211
assert path.startswith("/"), "Routed paths must start with '/'"
213
self.endpoint = endpoint
214
self.name = get_name(endpoint) if name is None else name
215
self.include_in_schema = include_in_schema
217
endpoint_handler = endpoint
218
while isinstance(endpoint_handler, functools.partial):
219
endpoint_handler = endpoint_handler.func
220
if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
221
# Endpoint is function or method. Treat it as `func(request) -> response`.
222
self.app = request_response(endpoint)
226
# Endpoint is a class. Treat it as ASGI.
229
if middleware is not None:
230
for cls, args, kwargs in reversed(middleware):
231
self.app = cls(self.app, *args, **kwargs)
232
if max_body_size is not None:
233
self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
238
self.methods = {method.upper() for method in methods}
239
if "GET" in self.methods:
240
self.methods.add("HEAD")
242
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
244
def matches(self, scope: Scope) -> tuple[Match, Scope]:
245
path_params: dict[str, Any]
246
if scope["type"] == "http":
247
route_path = get_route_path(scope)
248
match = self.path_regex.match(route_path)
250
matched_params = match.groupdict()
251
for key, value in matched_params.items():
252
matched_params[key] = self.param_convertors[key].convert(value)
253
path_params = dict(scope.get("path_params", {}))
254
path_params.update(matched_params)
255
child_scope = {"endpoint": self.endpoint, "path_params": path_params}
256
if self.methods and scope["method"] not in self.methods:
257
return Match.PARTIAL, child_scope
259
return Match.FULL, child_scope
260
return Match.NONE, {}
262
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
263
seen_params = set(path_params.keys())
264
expected_params = set(self.param_convertors.keys())
266
if name != self.name or seen_params != expected_params:
267
raise NoMatchFound(name, path_params)
269
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
270
assert not remaining_params
271
return URLPath(path=path, protocol="http")
273
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
274
if self.methods and scope["method"] not in self.methods:
275
headers = {"Allow": ", ".join(self.methods)}
277
raise HTTPException(status_code=405, headers=headers)
279
response = PlainTextResponse("Method Not Allowed", status_code=405, headers=headers)
280
await response(scope, receive, send)
282
273
await self.app(scope, receive, send)
284
def __eq__(self, other: Any) -> bool:
286
isinstance(other, Route)
287
and self.path == other.path
288
and self.endpoint == other.endpoint
289
and self.methods == other.methods
292
def __repr__(self) -> str:
293
class_name = self.__class__.__name__
294
methods = sorted(self.methods or [])
295
path, name = self.path, self.name
296
return f"{class_name}(path={path!r}, name={name!r}, methods={methods!r})"
299
class WebSocketRoute(BaseRoute):
303
endpoint: Callable[..., Any],
305
name: str | None = None,
306
middleware: Sequence[Middleware] | None = None,
308
assert path.startswith("/"), "Routed paths must start with '/'"
310
self.endpoint = endpoint
311
self.name = get_name(endpoint) if name is None else name
313
endpoint_handler = endpoint
314
while isinstance(endpoint_handler, functools.partial):
315
endpoint_handler = endpoint_handler.func
316
if inspect.isfunction(endpoint_handler) or inspect.ismethod(endpoint_handler):
317
# Endpoint is function or method. Treat it as `func(websocket)`.
318
self.app = websocket_session(endpoint)
320
# Endpoint is a class. Treat it as ASGI.
323
if middleware is not None:
324
for cls, args, kwargs in reversed(middleware):
325
self.app = cls(self.app, *args, **kwargs)
327
self.path_regex, self.path_format, self.param_convertors = compile_path(path)
329
def matches(self, scope: Scope) -> tuple[Match, Scope]:
330
path_params: dict[str, Any]
331
if scope["type"] == "websocket":
332
route_path = get_route_path(scope)
333
match = self.path_regex.match(route_path)
335
matched_params = match.groupdict()
336
for key, value in matched_params.items():
337
matched_params[key] = self.param_convertors[key].convert(value)
338
path_params = dict(scope.get("path_params", {}))
339
path_params.update(matched_params)
340
child_scope = {"endpoint": self.endpoint, "path_params": path_params}
341
return Match.FULL, child_scope
342
return Match.NONE, {}
344
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
345
seen_params = set(path_params.keys())
346
expected_params = set(self.param_convertors.keys())
348
if name != self.name or seen_params != expected_params:
349
raise NoMatchFound(name, path_params)
351
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
352
assert not remaining_params
353
return URLPath(path=path, protocol="websocket")
355
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
356
await self.app(scope, receive, send)
358
def __eq__(self, other: Any) -> bool:
359
return isinstance(other, WebSocketRoute) and self.path == other.path and self.endpoint == other.endpoint
361
def __repr__(self) -> str:
362
return f"{self.__class__.__name__}(path={self.path!r}, name={self.name!r})"
365
class Mount(BaseRoute):
369
app: ASGIApp | None = None,
370
routes: Sequence[BaseRoute] | None = None,
371
name: str | None = None,
373
middleware: Sequence[Middleware] | None = None,
374
max_body_size: int | None = None,
376
assert path == "" or path.startswith("/"), "Routed paths must start with '/'"
377
assert app is not None or routes is not None, "Either 'app=...', or 'routes=' must be specified"
378
self.path = path.rstrip("/")
380
self._base_app: ASGIApp = app
382
self._base_app = Router(routes=routes)
383
self.app = self._base_app
384
if middleware is not None:
385
for cls, args, kwargs in reversed(middleware):
386
self.app = cls(self.app, *args, **kwargs)
387
if max_body_size is not None:
388
self.app = RequestBodyLimitMiddleware(self.app, max_body_size=max_body_size)
390
self.path_regex, self.path_format, self.param_convertors = compile_path(self.path + "/{path:path}")
393
def routes(self) -> list[BaseRoute]:
394
return getattr(self._base_app, "routes", [])
396
def matches(self, scope: Scope) -> tuple[Match, Scope]:
397
path_params: dict[str, Any]
398
if scope["type"] in ("http", "websocket"): # pragma: no branch
399
root_path = scope.get("root_path", "")
400
route_path = get_route_path(scope)
401
match = self.path_regex.match(route_path)
403
matched_params = match.groupdict()
404
for key, value in matched_params.items():
405
matched_params[key] = self.param_convertors[key].convert(value)
406
remaining_path = "/" + matched_params.pop("path")
407
matched_path = route_path[: -len(remaining_path)]
408
path_params = dict(scope.get("path_params", {}))
409
path_params.update(matched_params)
411
"path_params": path_params,
412
# app_root_path will only be set at the top level scope,
413
# initialized with the (optional) value of a root_path
414
# set above/before Starlette. And even though any
415
# mount will have its own child scope with its own respective
416
# root_path, the app_root_path will always be available in all
417
# the child scopes with the same top level value because it's
418
# set only once here with a default, any other child scope will
419
# just inherit that app_root_path default value stored in the
420
# scope. All this is needed to support Request.url_for(), as it
421
# uses the app_root_path to build the URL path.
422
"app_root_path": scope.get("app_root_path", root_path),
423
"root_path": root_path + matched_path,
424
"endpoint": self.app,
426
return Match.FULL, child_scope
427
return Match.NONE, {}
429
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
430
if self.name is not None and name == self.name and "path" in path_params:
431
# 'name' matches "<mount_name>".
432
path_params["path"] = path_params["path"].lstrip("/")
433
path, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
434
if not remaining_params:
435
return URLPath(path=path)
436
elif self.name is None or name.startswith(self.name + ":"):
437
if self.name is None:
439
remaining_name = name
441
# 'name' matches "<mount_name>:<child_name>".
442
remaining_name = name[len(self.name) + 1 :]
443
path_kwarg = path_params.get("path")
444
path_params["path"] = ""
445
path_prefix, remaining_params = replace_params(self.path_format, self.param_convertors, path_params)
446
if path_kwarg is not None:
447
remaining_params["path"] = path_kwarg
448
for route in self.routes or []:
450
url = route.url_path_for(remaining_name, **remaining_params)
451
return URLPath(path=path_prefix.rstrip("/") + str(url), protocol=url.protocol)
454
raise NoMatchFound(name, path_params)
456
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
457
await self.app(scope, receive, send)
459
def __eq__(self, other: Any) -> bool:
460
return isinstance(other, Mount) and self.path == other.path and self.app == other.app
462
def __repr__(self) -> str:
463
class_name = self.__class__.__name__
464
name = self.name or ""
465
return f"{class_name}(path={self.path!r}, name={name!r}, app={self.app!r})"
468
class Host(BaseRoute):
469
def __init__(self, host: str, app: ASGIApp, name: str | None = None) -> None:
470
assert not host.startswith("/"), "Host must not start with '/'"
474
self.host_regex, self.host_format, self.param_convertors = compile_path(host)
477
def routes(self) -> list[BaseRoute]:
478
return getattr(self.app, "routes", [])
480
def matches(self, scope: Scope) -> tuple[Match, Scope]:
481
if scope["type"] in ("http", "websocket"): # pragma:no branch
482
headers = Headers(scope=scope)
483
parsed_host = parse_host_header(headers.get("host"))
484
if parsed_host is None:
485
return Match.NONE, {}
486
host = parsed_host.host
488
match = self.host_regex.match(host)
490
matched_params = match.groupdict()
491
for key, value in matched_params.items():
492
matched_params[key] = self.param_convertors[key].convert(value)
493
path_params = dict(scope.get("path_params", {}))
494
path_params.update(matched_params)
495
child_scope = {"path_params": path_params, "endpoint": self.app}
496
return Match.FULL, child_scope
497
return Match.NONE, {}
499
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
500
if self.name is not None and name == self.name and "path" in path_params:
501
# 'name' matches "<mount_name>".
502
path = path_params.pop("path")
503
host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
504
if not remaining_params:
505
return URLPath(path=path, host=host)
506
elif self.name is None or name.startswith(self.name + ":"):
507
if self.name is None:
509
remaining_name = name
511
# 'name' matches "<mount_name>:<child_name>".
512
remaining_name = name[len(self.name) + 1 :]
513
host, remaining_params = replace_params(self.host_format, self.param_convertors, path_params)
514
for route in self.routes or []:
516
url = route.url_path_for(remaining_name, **remaining_params)
517
return URLPath(path=str(url), protocol=url.protocol, host=host)
520
raise NoMatchFound(name, path_params)
522
async def handle(self, scope: Scope, receive: Receive, send: Send) -> None:
523
await self.app(scope, receive, send)
525
def __eq__(self, other: Any) -> bool:
526
return isinstance(other, Host) and self.host == other.host and self.app == other.app
528
def __repr__(self) -> str:
529
class_name = self.__class__.__name__
530
name = self.name or ""
531
return f"{class_name}(host={self.host!r}, name={name!r}, app={self.app!r})"
537
class _AsyncLiftContextManager(AbstractAsyncContextManager[_T]):
538
def __init__(self, cm: AbstractContextManager[_T]):
541
async def __aenter__(self) -> _T:
542
return self._cm.__enter__()
546
exc_type: type[BaseException] | None,
547
exc_value: BaseException | None,
548
traceback: types.TracebackType | None,
550
return self._cm.__exit__(exc_type, exc_value, traceback)
553
def _wrap_gen_lifespan_context(
554
lifespan_context: Callable[[Any], Generator[Any, Any, Any]],
555
) -> Callable[[Any], AbstractAsyncContextManager[Any]]:
556
cmgr = contextlib.contextmanager(lifespan_context)
558
@functools.wraps(cmgr)
559
def wrapper(app: Any) -> _AsyncLiftContextManager[Any]:
560
return _AsyncLiftContextManager(cmgr(app))
565
class _DefaultLifespan:
566
def __init__(self, router: Router):
567
self._router = router
569
async def __aenter__(self) -> None:
572
async def __aexit__(self, *exc_info: object) -> None:
575
def __call__(self: _T, app: object) -> _T:
582
routes: Sequence[BaseRoute] | None = None,
583
redirect_slashes: bool = True,
584
default: ASGIApp | None = None,
585
# the generic to Lifespan[AppType] is the type of the top level application
586
# which the router cannot know statically, so we use Any
587
lifespan: Lifespan[Any] | None = None,
589
middleware: Sequence[Middleware] | None = None,
590
max_body_size: int | None = None,
592
self.routes = [] if routes is None else list(routes)
593
self.redirect_slashes = redirect_slashes
594
self.default = self.not_found if default is None else default
597
self.lifespan_context: Lifespan[Any] = _DefaultLifespan(self)
599
elif inspect.isasyncgenfunction(lifespan):
601
"async generator function lifespans are deprecated, "
602
"use an @contextlib.asynccontextmanager function instead",
603
StarletteDeprecationWarning,
605
self.lifespan_context = asynccontextmanager(lifespan)
606
elif inspect.isgeneratorfunction(lifespan):
608
"generator function lifespans are deprecated, use an @contextlib.asynccontextmanager function instead",
609
StarletteDeprecationWarning,
611
self.lifespan_context = _wrap_gen_lifespan_context(lifespan)
613
self.lifespan_context = lifespan
615
self.middleware_stack = self.app
617
for cls, args, kwargs in reversed(middleware):
618
self.middleware_stack = cls(self.middleware_stack, *args, **kwargs)
619
if max_body_size is not None:
620
self.middleware_stack = RequestBodyLimitMiddleware(self.middleware_stack, max_body_size=max_body_size)
622
async def not_found(self, scope: Scope, receive: Receive, send: Send) -> None:
623
if scope["type"] == "websocket":
624
websocket_close = WebSocketClose()
625
await websocket_close(scope, receive, send)
628
# If we're running inside a starlette application then raise an
629
# exception, so that the configurable exception handler can deal with
630
# returning the response. For plain ASGI apps, just return the response.
632
raise HTTPException(status_code=404)
634
response = PlainTextResponse("Not Found", status_code=404)
635
await response(scope, receive, send)
637
def url_path_for(self, name: str, /, **path_params: Any) -> URLPath:
638
for route in self.routes:
640
return route.url_path_for(name, **path_params)
643
raise NoMatchFound(name, path_params)
645
async def lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
647
Handle ASGI lifespan messages, which allows us to manage application
648
startup and shutdown events.
651
app: Any = scope.get("app")
654
async with self.lifespan_context(app) as maybe_state:
655
if maybe_state is not None:
656
if "state" not in scope:
657
raise RuntimeError('The server does not support "state" in the lifespan scope.')
658
scope["state"].update(maybe_state)
659
await send({"type": "lifespan.startup.complete"})
662
except BaseException:
663
exc_text = traceback.format_exc()
665
await send({"type": "lifespan.shutdown.failed", "message": exc_text})
667
await send({"type": "lifespan.startup.failed", "message": exc_text})
670
await send({"type": "lifespan.shutdown.complete"})
672
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
674
The main entry point to the Router class.
676
275
await self.middleware_stack(scope, receive, send)
678
async def app(self, scope: Scope, receive: Receive, send: Send) -> None:
679
assert scope["type"] in ("http", "websocket", "lifespan")
681
if "router" not in scope:
682
scope["router"] = self
684
if scope["type"] == "lifespan":
685
await self.lifespan(scope, receive, send)
690
for route in self.routes:
691
# Determine if any route matches the incoming scope,
692
# and hand over to the matching route if found.
693
match, child_scope = route.matches(scope)
694
if match == Match.FULL:
695
scope["route"] = route
696
scope.update(child_scope)
697
2
275
await route.handle(scope, receive, send)
699
elif match == Match.PARTIAL and partial is None:
701
partial_scope = child_scope
703
if partial is not None:
704
# Handle partial matches. These are cases where an endpoint is
705
# able to handle the request, but is not a preferred option.
706
# We use this in particular to deal with "405 Method Not Allowed".
707
scope["route"] = partial
708
scope.update(partial_scope)
709
await partial.handle(scope, receive, send)
712
route_path = get_route_path(scope)
713
if scope["type"] == "http" and self.redirect_slashes and route_path != "/":
714
redirect_scope = dict(scope)
715
if route_path.endswith("/"):
716
redirect_scope["path"] = redirect_scope["path"].rstrip("/")
718
redirect_scope["path"] = redirect_scope["path"] + "/"
720
for route in self.routes:
721
match, child_scope = route.matches(redirect_scope)
722
if match != Match.NONE:
723
redirect_url = URL(scope=redirect_scope)
724
response = RedirectResponse(url=str(redirect_url))
725
await response(scope, receive, send)
728
await self.default(scope, receive, send)
730
def __eq__(self, other: Any) -> bool:
731
return isinstance(other, Router) and self.routes == other.routes
733
def mount(self, path: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
734
route = Mount(path, app=app, name=name)
735
self.routes.append(route)
737
def host(self, host: str, app: ASGIApp, name: str | None = None) -> None: # pragma: no cover
738
route = Host(host, app=app, name=name)
739
self.routes.append(route)
744
endpoint: Callable[[Request], Awaitable[Response] | Response],
745
methods: Collection[str] | None = None,
746
name: str | None = None,
747
include_in_schema: bool = True,
748
) -> None: # pragma: no cover
754
include_in_schema=include_in_schema,
756
self.routes.append(route)
758
def add_websocket_route(
761
endpoint: Callable[[WebSocket], Awaitable[None]],
762
name: str | None = None,
763
) -> None: # pragma: no cover
764
route = WebSocketRoute(path, endpoint=endpoint, name=name)
765
self.routes.append(route)