diff --git a/esmerald/__init__.py b/esmerald/__init__.py index 52024376..4f48e3f5 100644 --- a/esmerald/__init__.py +++ b/esmerald/__init__.py @@ -33,7 +33,7 @@ from .routing.apis import APIView, Controller, SimpleAPIView from .routing.gateways import Gateway, WebhookGateway, WebSocketGateway from .routing.handlers import delete, get, head, options, patch, post, put, route, trace, websocket -from .routing.router import Include, Router +from .routing.router import Host, Include, Router from .routing.webhooks import ( whdelete, whead, @@ -75,6 +75,7 @@ "Include", "Inject", "Factory", + "Host", "Injects", "ImproperlyConfigured", "JSON", diff --git a/esmerald/core/directives/operations/show_urls.py b/esmerald/core/directives/operations/show_urls.py index 20433811..1b5ab524 100644 --- a/esmerald/core/directives/operations/show_urls.py +++ b/esmerald/core/directives/operations/show_urls.py @@ -8,7 +8,7 @@ from rich.console import Console from rich.table import Table -from esmerald import Gateway +from esmerald import Gateway, Router from esmerald.core.directives.constants import ESMERALD_DISCOVER_APP from esmerald.core.directives.env import DirectiveEnv from esmerald.core.terminal import OutputColour, Print, Terminal @@ -19,7 +19,6 @@ from lilya.routing import BasePath from esmerald.applications import ChildEsmerald, Esmerald - from esmerald.routing.router import Router printer = Print() writer = Terminal() @@ -48,6 +47,10 @@ def get_http_verb(mapping: Any) -> str: return HttpMethod.DELETE.value elif getattr(mapping, "header", None): return HttpMethod.HEAD.value + elif getattr(mapping, "trace", None): + return HttpMethod.TRACE.value + elif getattr(mapping, "options", None): + return HttpMethod.OPTIONS.value return HttpMethod.GET.value @@ -77,7 +80,7 @@ def get_routes_table(app: Optional[Union["Esmerald", "ChildEsmerald"]], table: T """Prints the routing system""" table.add_column("Path", style=OutputColour.GREEN, vertical="middle") table.add_column("Path Parameters", style=OutputColour.BRIGHT_CYAN, vertical="middle") - table.add_column("Name", style=OutputColour.CYAN, vertical="middle") + table.add_column("Name & Path Lookup", style=OutputColour.CYAN, vertical="middle") table.add_column("Type", style=OutputColour.YELLOW, vertical="middle") table.add_column("HTTP Methods", style=OutputColour.RED, vertical="middle") @@ -106,9 +109,15 @@ def parse_routes( fn_type = "sync" # Http methods + names = route.handler.get_lookup_path() + + # We need to escape the character ':' to avoid the error + # of the table not being able to render the string + route_name = ":\u200d".join(names) + http_methods = ", ".join(sorted(route.methods)) parameters = ", ".join(sorted(route.stringify_parameters)) - table.add_row(path, parameters, route.name, fn_type, http_methods) + table.add_row(path, parameters, route_name, fn_type, http_methods) continue route_app = getattr(route, "app", None) @@ -118,7 +127,6 @@ def parse_routes( path = clean_path(prefix + route.path) # type: ignore if any(element in path for element in DOCS_ELEMENTS): continue - parse_routes(route, table, prefix=f"{path}") parse_routes(app, table) diff --git a/esmerald/openapi/openapi.py b/esmerald/openapi/openapi.py index 6dc91c46..1201e190 100644 --- a/esmerald/openapi/openapi.py +++ b/esmerald/openapi/openapi.py @@ -178,7 +178,8 @@ def get_openapi_operation( if route.handler.summary: operation.summary = route.handler.summary else: - operation.summary = route.handler.name.replace("_", " ").replace("-", " ").title() + name = route.handler.name or route.name + operation.summary = name.replace("_", " ").replace("-", " ").title() # Handle the handler description if route.handler.description: @@ -190,7 +191,7 @@ def get_openapi_operation( if operation_id in operation_ids: message = ( - f"Duplicate Operation ID {operation_id} for function " + f"{route.handler.__name__}" + f"Duplicate Operation ID {operation_id} for function " + f"{route.handler.fn.__name__}" ) file_name = getattr(route.handler, "__globals__", {}).get("__file__") if file_name: diff --git a/esmerald/routing/apis/base.py b/esmerald/routing/apis/base.py index 326d07ad..bfe26a65 100644 --- a/esmerald/routing/apis/base.py +++ b/esmerald/routing/apis/base.py @@ -387,12 +387,18 @@ def get_routes( route_kwargs = { "path": path, "handler": route_handler, - "name": name or route_handler.fn.__name__, "middleware": middleware, "interceptors": interceptors, "permissions": permissions, "exception_handlers": exception_handlers, } + + route_name_list = [ + self.parent.name, + ] + + # Make sure the proper name is set for the route + route_kwargs["name"] = ":".join(route_name_list) route_path = ( Gateway(**route_kwargs) # type: ignore if isinstance(route_handler, HTTPHandler) diff --git a/esmerald/routing/base.py b/esmerald/routing/base.py index 70ba00b1..6ea4a5e6 100644 --- a/esmerald/routing/base.py +++ b/esmerald/routing/base.py @@ -674,6 +674,29 @@ def parent_levels(self) -> List[Any]: current = current.parent return list(reversed(levels)) + def get_lookup_path(self) -> List[str]: + """ + Constructs and returns the lookup path for the current object by traversing + its parent hierarchy. + + The method collects the 'name' attribute of the current object and its + ancestors, if they exist, and returns them as a list in reverse order + (from the root ancestor to the current object). + + Returns: + List[str]: A list of names representing the lookup path from the root + ancestor to the current object. + """ + + names = [] + current: Any = self + + while current: + if getattr(current, "name", None) is not None: + names.append(current.name) + current = current.parent + return list(reversed(names)) + @property def dependency_names(self) -> Set[str]: """ diff --git a/esmerald/routing/gateways.py b/esmerald/routing/gateways.py index 3ce8733a..65128cfd 100644 --- a/esmerald/routing/gateways.py +++ b/esmerald/routing/gateways.py @@ -42,7 +42,6 @@ def handle_middleware( class GatewayUtil: - def is_class_based( self, handler: Union["HTTPHandler", "WebSocketHandler", "ParentType"] ) -> bool: @@ -60,9 +59,7 @@ def generate_operation_id( We need to be able to handle with edge cases when a view does not default a path like `/format` and a default name needs to be passed when its a class based view. """ if self.is_class_based(handler.parent): - operation_id = ( - handler.parent.__class__.__name__.lower() + f"_{name}" + handler.path_format - ) + operation_id = handler.parent.__class__.__name__.lower() + handler.path_format else: operation_id = name + handler.path_format @@ -286,10 +283,16 @@ def __init__( if not name: if not isinstance(handler, View): - name = clean_string(handler.fn.__name__) + name = handler.name or clean_string(handler.fn.__name__) else: name = clean_string(handler.__class__.__name__) + else: + route_name_list = [name] + if not isinstance(handler, View) and handler.name: + route_name_list.append(handler.name) + name = ":".join(route_name_list) + # Handle middleware self.middleware = middleware or [] self._middleware: List["Middleware"] = self.handle_middleware( @@ -328,17 +331,17 @@ def __init__( self.operation_id = operation_id if self.is_handler(self.handler): # type: ignore - self.handler.name = self.name - if self.operation_id or handler.operation_id is not None: handler_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name or "", + handler=self.handler, # type: ignore ) self.operation_id = f"{operation_id}_{handler_id}" if operation_id else handler_id elif not handler.operation_id: handler.operation_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name or "", + handler=self.handler, # type: ignore ) async def handle_dispatch(self, scope: "Scope", receive: "Receive", send: "Send") -> None: @@ -503,10 +506,16 @@ def __init__( if not name: if not isinstance(handler, View): - name = clean_string(handler.fn.__name__) + name = handler.name or clean_string(handler.fn.__name__) else: name = clean_string(handler.__class__.__name__) + else: + route_name_list = [name] + if not isinstance(handler, View) and handler.name: + route_name_list.append(handler.name) + name = ":".join(route_name_list) + # Handle middleware self.middleware = middleware or [] self._middleware: List["Middleware"] = self.handle_middleware( @@ -691,5 +700,6 @@ def __init__( if not handler.operation_id: handler.operation_id = self.generate_operation_id( - name=self.name, handler=self.handler # type: ignore + name=self.name, + handler=self.handler, # type: ignore ) diff --git a/esmerald/routing/handlers.py b/esmerald/routing/handlers.py index 1cda8c05..89379677 100644 --- a/esmerald/routing/handlers.py +++ b/esmerald/routing/handlers.py @@ -50,6 +50,14 @@ def get( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -397,6 +405,7 @@ async def get() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.GET], summary=summary, description=description, @@ -441,6 +450,14 @@ def head( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -666,6 +683,7 @@ def head( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.HEAD], summary=summary, description=description, @@ -722,6 +740,14 @@ def post( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1074,6 +1100,7 @@ async def create() -> None: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, status_code=status_code, content_encoding=content_encoding, content_media_type=content_media_type, @@ -1130,6 +1157,14 @@ def put( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1477,6 +1512,7 @@ async def update() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.PUT], summary=summary, description=description, @@ -1533,6 +1569,14 @@ def patch( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -1880,6 +1924,7 @@ async def update_partial() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.PATCH], summary=summary, description=description, @@ -1936,6 +1981,14 @@ def delete( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2283,6 +2336,7 @@ async def remove() -> None: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.DELETE], summary=summary, description=description, @@ -2327,6 +2381,14 @@ def options( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2552,6 +2614,7 @@ def options( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.OPTIONS], summary=summary, description=description, @@ -2596,6 +2659,14 @@ def trace( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, summary: Annotated[ Optional[str], Doc( @@ -2822,6 +2893,7 @@ def trace( def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=[HttpMethod.TRACE], summary=summary, description=description, @@ -2878,6 +2950,14 @@ def route( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, methods: Annotated[ List[str], Doc( @@ -3259,6 +3339,7 @@ async def operate() -> str: def wrapper(func: Any) -> HTTPHandler: handler = HTTPHandler( path=path, + name=name, methods=methods, summary=summary, description=description, @@ -3316,6 +3397,14 @@ def websocket( ), ] = None, *, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, dependencies: Annotated[ Optional["Dependencies"], Doc( @@ -3356,6 +3445,7 @@ def wrapper(func: Any) -> WebSocketHandler: exception_handlers=exception_handlers, permissions=permissions, middleware=middleware, + name=name, ) handler.fn = func handler.handler = func diff --git a/esmerald/routing/router.py b/esmerald/routing/router.py index fb205991..a54fe49e 100644 --- a/esmerald/routing/router.py +++ b/esmerald/routing/router.py @@ -481,9 +481,9 @@ async def another(request: Request) -> str: path = "/" else: assert path.startswith("/"), "A path prefix must start with '/'" - assert not path.endswith("/"), ( - "A path must not end with '/', as the routes will start with '/'" - ) + assert not path.endswith( + "/" + ), "A path must not end with '/', as the routes will start with '/'" new_routes: list[Any] = [] for route in routes or []: @@ -511,9 +511,9 @@ async def another(request: Request) -> str: ) new_routes.append(route) - assert lifespan is None or (on_startup is None and on_shutdown is None), ( - "Use either 'lifespan' or 'on_startup'/'on_shutdown', not both." - ) + assert lifespan is None or ( + on_startup is None and on_shutdown is None + ), "Use either 'lifespan' or 'on_startup'/'on_shutdown', not both." super().__init__( redirect_slashes=redirect_slashes, @@ -1929,6 +1929,7 @@ class HTTPHandler(Dispatcher, OpenAPIFieldInfoMixin, LilyaPath): "_dependencies", "_response_handler", "_middleware", + "name", "methods", "status_code", "content_encoding", @@ -1958,6 +1959,7 @@ def __init__( path: Optional[str] = None, handler: Callable[..., Any] = None, *, + name: Optional[str] = None, methods: Optional[Sequence[str]] = None, status_code: Optional[int] = None, content_encoding: Optional[str] = None, @@ -1991,6 +1993,7 @@ def __init__( handler=handler, include_in_schema=include_in_schema, exception_handlers=exception_handlers, + name=name, ) self._permissions: Union[List[Permission], VoidType] = Void @@ -2002,6 +2005,7 @@ def __init__( self.path = path self.handler = handler self.summary = summary + self.name = name self.tags = tags or [] self.include_in_schema = include_in_schema self.deprecated = deprecated @@ -2379,6 +2383,7 @@ class WebSocketHandler(Dispatcher, LilyaWebSocketPath): "permissions", "middleware", "parent", + "name", "__type__", ) @@ -2391,6 +2396,14 @@ def __init__( exception_handlers: Optional[ExceptionHandlerMap] = None, permissions: Optional[List[Permission]] = None, middleware: Optional[List[Middleware]] = None, + name: Annotated[ + Optional[str], + Doc( + """ + The name for the Gateway. The name can be reversed by `path_for()`. + """ + ), + ] = None, ): if not path: path = "/" @@ -2411,6 +2424,7 @@ def __init__( self.fn: Optional[AnyCallable] = None self.tags: Sequence[str] = [] self.__type__: Union[str, None] = None + self.name = name def validate_reserved_words(self, signature: Signature) -> None: """ diff --git a/tests/openapi/test_duplicate_operation_id.py b/tests/openapi/test_duplicate_operation_id.py index 1d270383..54efa8f8 100644 --- a/tests/openapi/test_duplicate_operation_id.py +++ b/tests/openapi/test_duplicate_operation_id.py @@ -64,9 +64,9 @@ def test_open_api_schema(test_client_factory): "/api/v1/admin/users": { "post": { "tags": ["User"], - "summary": "Create", + "summary": "Userapiview", "description": "", - "operationId": "userapiview_create__post", + "operationId": "userapiview__post", "responses": { "201": { "description": "Successful response", @@ -79,9 +79,9 @@ def test_open_api_schema(test_client_factory): "/api/v1/admin/profiles": { "post": { "tags": ["Profile"], - "summary": "Create", + "summary": "Profileapiview", "description": "", - "operationId": "profileapiview_create__post", + "operationId": "profileapiview__post", "responses": { "201": { "description": "Successful response", diff --git a/tests/openapi/test_include_with_apiview.py b/tests/openapi/test_include_with_apiview.py index 7f65374c..1de56479 100644 --- a/tests/openapi/test_include_with_apiview.py +++ b/tests/openapi/test_include_with_apiview.py @@ -25,6 +25,7 @@ class MyAPI(value): @get( "/item", + summary="Read Item", description="Read an item", responses={ 200: OpenAPIResponse(model=Item, description="The SKU information of an item") @@ -57,7 +58,7 @@ async def read_item(self) -> JSON: ... "get": { "summary": "Read Item", "description": "Read an item", - "operationId": "myapi_read_item_item_get", + "operationId": "myapi_item_get", "responses": { "200": { "description": "The SKU information of an item", diff --git a/tests/routing/test_path_lookup.py b/tests/routing/test_path_lookup.py new file mode 100644 index 00000000..d7b71638 --- /dev/null +++ b/tests/routing/test_path_lookup.py @@ -0,0 +1,166 @@ +from lilya.compat import reverse + +from esmerald import Controller, Gateway, Include, get, post +from esmerald.testclient import create_client + + +@get("/hello") +async def get_hello() -> str: + return "Hello World" + + +@post("/new") +async def post_new() -> str: + return "New World" + + +@get("/home", name="home") +async def home() -> str: + return "Hello World" + + +@post("/new-home", name="new-home") +async def post_new_home() -> str: + return "New World" + + +class TestController(Controller): + @get("/int", name="int") + async def get_int(self, id: int) -> int: + return id + + +class Test2Controller(Controller): + path = "/test" + + @get("/int", name="int") + async def get_int(self, id: int) -> int: + return id + + +routes = [ + Include( + "/api", + routes=[ + Include( + routes=[ + Gateway(handler=get_hello, name="hello"), + Gateway(handler=post_new, name="new"), + Gateway(handler=home, name="home"), + Gateway(handler=post_new_home, name="new-home"), + Gateway(handler=TestController, name="test"), + Gateway(handler=Test2Controller), + ], + name="v1", + ), + ], + name="api", + ), + Include( + "/esmerald", + routes=[ + Include( + "/api", + routes=[ + Include( + routes=[ + Gateway(handler=get_hello, name="hello"), + Gateway(handler=post_new, name="new"), + Gateway(handler=home, name="home"), + Gateway(handler=post_new_home, name="new-home"), + Gateway(handler=TestController, name="test"), + Gateway(handler=Test2Controller), + ], + name="v1", + ), + ], + name="api", + ), + ], + name="esmerald", + ), +] + + +def test_can_reverse_simple(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:hello") == "/api/hello" + assert app.path_for("api:v1:new") == "/api/new" + + assert reverse("api:v1:hello") == "/api/hello" + assert reverse("api:v1:new") == "/api/new" + + +def test_can_reverse_with_gateway_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:home:home") == "/api/home" + assert reverse("api:v1:home:home") == "/api/home" + + assert app.path_for("api:v1:new-home:new-home") == "/api/new-home" + assert reverse("api:v1:new-home:new-home") == "/api/new-home" + + +def test_can_reverse_with_controller_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:test:int") == "/api/int" + assert reverse("api:v1:test:int") == "/api/int" + + +def test_can_reverse_with_no_controller_name_and_handler_name(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:test2controller:int") == "/api/test/int" + assert reverse("api:v1:test2controller:int") == "/api/test/int" + + +def test_can_reverse_lookup_all(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("api:v1:hello") == "/api/hello" + assert app.path_for("api:v1:new") == "/api/new" + + assert reverse("api:v1:hello") == "/api/hello" + assert reverse("api:v1:new") == "/api/new" + + assert app.path_for("api:v1:home:home") == "/api/home" + assert reverse("api:v1:home:home") == "/api/home" + + assert app.path_for("api:v1:new-home:new-home") == "/api/new-home" + assert reverse("api:v1:new-home:new-home") == "/api/new-home" + + assert app.path_for("api:v1:test:int") == "/api/int" + assert reverse("api:v1:test:int") == "/api/int" + + assert app.path_for("api:v1:test2controller:int") == "/api/test/int" + assert reverse("api:v1:test2controller:int") == "/api/test/int" + + +def test_can_reverse_lookup_all_nested(test_client_factory): + with create_client(routes=routes, enable_openapi=False) as client: + app = client.app + + assert app.path_for("esmerald:api:v1:hello") == "/esmerald/api/hello" + assert app.path_for("esmerald:api:v1:new") == "/esmerald/api/new" + + assert reverse("esmerald:api:v1:hello") == "/esmerald/api/hello" + assert reverse("esmerald:api:v1:new") == "/esmerald/api/new" + + assert app.path_for("esmerald:api:v1:home:home") == "/esmerald/api/home" + assert reverse("esmerald:api:v1:home:home") == "/esmerald/api/home" + + assert app.path_for("esmerald:api:v1:new-home:new-home") == "/esmerald/api/new-home" + assert reverse("esmerald:api:v1:new-home:new-home") == "/esmerald/api/new-home" + + assert app.path_for("esmerald:api:v1:test:int") == "/esmerald/api/int" + assert reverse("esmerald:api:v1:test:int") == "/esmerald/api/int" + + assert app.path_for("esmerald:api:v1:test2controller:int") == "/esmerald/api/test/int" + assert reverse("esmerald:api:v1:test2controller:int") == "/esmerald/api/test/int"