diff --git a/flama/applications.py b/flama/applications.py index d975a47d..201ec987 100644 --- a/flama/applications.py +++ b/flama/applications.py @@ -159,6 +159,7 @@ def add_route( name: t.Optional[str] = None, include_in_schema: bool = True, route: t.Optional["Route"] = None, + **tags: t.Any, ) -> "Route": """Register a new HTTP route or endpoint under given path. @@ -168,9 +169,17 @@ def add_route( :param name: Endpoint or route name. :param include_in_schema: True if this route or endpoint should be declared as part of the API schema. :param route: HTTP route. + :param tags: Tags to add to the route. """ return self.router.add_route( - path, endpoint, methods=methods, name=name, include_in_schema=include_in_schema, route=route, root=self + path, + endpoint, + methods=methods, + name=name, + include_in_schema=include_in_schema, + route=route, + root=self, + **tags, ) def route( @@ -179,6 +188,7 @@ def route( methods: t.Optional[t.List[str]] = None, name: t.Optional[str] = None, include_in_schema: bool = True, + **tags: t.Any, ) -> t.Callable[[types.HTTPHandler], types.HTTPHandler]: """Decorator version for registering a new HTTP route in this router under given path. @@ -186,9 +196,12 @@ def route( :param methods: List of valid HTTP methods (only applies for routes). :param name: Endpoint or route name. :param include_in_schema: True if this route or endpoint should be declared as part of the API schema. + :param tags: Tags to add to the route. :return: Decorated route. """ - return self.router.route(path, methods=methods, name=name, include_in_schema=include_in_schema, root=self) + return self.router.route( + path, methods=methods, name=name, include_in_schema=include_in_schema, root=self, **tags + ) def add_websocket_route( self, @@ -196,6 +209,7 @@ def add_websocket_route( endpoint: t.Optional[types.WebSocketHandler] = None, name: t.Optional[str] = None, route: t.Optional["WebSocketRoute"] = None, + **tags: t.Any, ) -> "WebSocketRoute": """Register a new websocket route or endpoint under given path. @@ -203,19 +217,21 @@ def add_websocket_route( :param endpoint: Websocket endpoint. :param name: Endpoint or route name. :param route: Websocket route. + :param tags: Tags to add to the websocket route. """ - return self.router.add_websocket_route(path, endpoint, name=name, route=route, root=self) + return self.router.add_websocket_route(path, endpoint, name=name, route=route, root=self, **tags) def websocket_route( - self, path: str, name: t.Optional[str] = None + self, path: str, name: t.Optional[str] = None, **tags: t.Any ) -> t.Callable[[types.WebSocketHandler], types.WebSocketHandler]: """Decorator version for registering a new websocket route in this router under given path. :param path: URL path. :param name: Websocket route name. + :param tags: Tags to add to the websocket route. :return: Decorated route. """ - return self.router.websocket_route(path, name=name, root=self) + return self.router.websocket_route(path, name=name, root=self, **tags) def mount( self, @@ -223,6 +239,7 @@ def mount( app: t.Optional[types.App] = None, name: t.Optional[str] = None, mount: t.Optional["Mount"] = None, + **tags: t.Any, ) -> "Mount": """Register a new mount point containing an ASGI app in this router under given path. @@ -230,9 +247,10 @@ def mount( :param app: ASGI app to mount. :param name: Application name. :param mount: Mount. + :param tags: Tags to add to the mount. :return: Mount. """ - return self.router.mount(path, app, name=name, mount=mount, root=self) + return self.router.mount(path, app, name=name, mount=mount, root=self, **tags) @property def injector(self) -> injection.Injector: diff --git a/flama/routing.py b/flama/routing.py index 4c6b7b89..48944778 100644 --- a/flama/routing.py +++ b/flama/routing.py @@ -182,6 +182,7 @@ def __init__( *, name: t.Optional[str] = None, include_in_schema: bool = True, + **tags: t.Any, ): """A route definition of a http endpoint. @@ -189,12 +190,14 @@ def __init__( :param app: ASGI application. :param name: Route name. :param include_in_schema: True if this route must be listed as part of the App schema. + :param tags: Route tags. """ self.path = url.RegexPath(path) self.app = app self.endpoint = app.handler if isinstance(app, EndpointWrapper) else app self.name = name self.include_in_schema = include_in_schema + self.tags = tags super().__init__() async def __call__(self, scope: types.Scope, receive: types.Receive, send: types.Send) -> None: @@ -288,6 +291,7 @@ def __init__( methods: t.Optional[t.Union[t.Set[str], t.Sequence[str]]] = None, name: t.Optional[str] = None, include_in_schema: bool = True, + **tags: t.Any, ) -> None: """A route definition of a http endpoint. @@ -296,6 +300,7 @@ def __init__( :param methods: List of valid HTTP methods. :param name: Route name. :param include_in_schema: True if this route must be listed as part of the App schema. + :param tags: Route tags. """ assert self.is_endpoint(endpoint) or ( not inspect.isclass(endpoint) and callable(endpoint) @@ -312,7 +317,11 @@ def __init__( name = endpoint.__name__ if name is None else name super().__init__( - path, EndpointWrapper(endpoint, EndpointWrapper.type.http), name=name, include_in_schema=include_in_schema + path, + EndpointWrapper(endpoint, EndpointWrapper.type.http), + name=name, + include_in_schema=include_in_schema, + **tags, ) self.app: EndpointWrapper @@ -369,6 +378,7 @@ def __init__( *, name: t.Optional[str] = None, include_in_schema: bool = True, + **tags: t.Any, ): """A route definition of a websocket endpoint. @@ -376,6 +386,7 @@ def __init__( :param endpoint: Websocket endpoint or function. :param name: Route name. :param include_in_schema: True if this route must be listed as part of the App schema. + :param tags: Route tags. """ assert self.is_endpoint(endpoint) or ( @@ -389,6 +400,7 @@ def __init__( EndpointWrapper(endpoint, EndpointWrapper.type.websocket), name=name, include_in_schema=include_in_schema, + **tags, ) self.app: EndpointWrapper @@ -435,6 +447,7 @@ def __init__( routes: t.Optional[t.Sequence[BaseRoute]] = None, components: t.Optional[t.Sequence[Component]] = None, name: t.Optional[str] = None, + **tags: t.Any, ): """A mount point for adding a nested ASGI application or a list of routes. @@ -443,13 +456,14 @@ def __init__( :param routes: List of routes. :param components: Components registered under this mount point. :param name: Mount name. + :param tags: Mount tags. """ assert app is not None or routes is not None, "Either 'app' or 'routes' must be specified" if app is None: app = Router(routes=routes, components=components) - super().__init__(url.RegexPath(path.rstrip("/") + "{path:path}"), app, name=name) + super().__init__(url.RegexPath(path.rstrip("/") + "{path:path}"), app, name=name, **tags) def __eq__(self, other: t.Any) -> bool: return super().__eq__(other) and isinstance(other, Mount) @@ -618,6 +632,7 @@ def add_route( include_in_schema: bool = True, route: t.Optional[Route] = None, root: t.Optional["Flama"] = None, + **tags: t.Any, ) -> Route: """Register a new HTTP route in this router under given path. @@ -628,10 +643,13 @@ def add_route( :param include_in_schema: True if this route or endpoint should be declared as part of the API schema. :param route: HTTP route. :param root: Flama application. + :param tags: Tags to add to the route or endpoint. :return: Route. """ if path is not None and endpoint is not None: - route = Route(path, endpoint=endpoint, methods=methods, name=name, include_in_schema=include_in_schema) + route = Route( + path, endpoint=endpoint, methods=methods, name=name, include_in_schema=include_in_schema, **tags + ) assert route is not None, "Either 'path' and 'endpoint' or 'route' variables are needed" @@ -648,6 +666,7 @@ def route( name: t.Optional[str] = None, include_in_schema: bool = True, root: t.Optional["Flama"] = None, + **tags: t.Any, ) -> t.Callable[[types.HTTPHandler], types.HTTPHandler]: """Decorator version for registering a new HTTP route in this router under given path. @@ -656,11 +675,14 @@ def route( :param name: Endpoint or route name. :param include_in_schema: True if this route or endpoint should be declared as part of the API schema. :param root: Flama application. + :param tags: Tags to add to the endpoint. :return: Decorated route. """ def decorator(func: types.HTTPHandler) -> types.HTTPHandler: - self.add_route(path, func, methods=methods, name=name, include_in_schema=include_in_schema, root=root) + self.add_route( + path, func, methods=methods, name=name, include_in_schema=include_in_schema, root=root, **tags + ) return func return decorator @@ -672,6 +694,7 @@ def add_websocket_route( name: t.Optional[str] = None, route: t.Optional[WebSocketRoute] = None, root: t.Optional["Flama"] = None, + **tags: t.Any, ) -> WebSocketRoute: """Register a new websocket route in this router under given path. @@ -680,10 +703,11 @@ def add_websocket_route( :param name: Websocket route name. :param route: Specific route class. :param root: Flama application. + :param tags: Tags to add to the websocket route. :return: Websocket route. """ if path is not None and endpoint is not None: - route = WebSocketRoute(path, endpoint=endpoint, name=name) + route = WebSocketRoute(path, endpoint=endpoint, name=name, **tags) assert route is not None, "Either 'path' and 'endpoint' or 'route' variables are needed" @@ -694,18 +718,23 @@ def add_websocket_route( return route def websocket_route( - self, path: str, name: t.Optional[str] = None, root: t.Optional["Flama"] = None + self, + path: str, + name: t.Optional[str] = None, + root: t.Optional["Flama"] = None, + **tags: t.Any, ) -> t.Callable[[types.WebSocketHandler], types.WebSocketHandler]: """Decorator version for registering a new websocket route in this router under given path. :param path: URL path. :param name: Websocket route name. :param root: Flama application. + :param tags: Tags to add to the websocket route. :return: Decorated websocket route. """ def decorator(func: types.WebSocketHandler) -> types.WebSocketHandler: - self.add_websocket_route(path, func, name=name, root=root) + self.add_websocket_route(path, func, name=name, root=root, **tags) return func return decorator @@ -717,6 +746,7 @@ def mount( name: t.Optional[str] = None, mount: t.Optional[Mount] = None, root: t.Optional["Flama"] = None, + **tags: t.Any, ) -> Mount: """Register a new mount point containing an ASGI app in this router under given path. @@ -725,10 +755,11 @@ def mount( :param name: Route name. :param mount: Mount. :param root: Flama application. + :param tags: Tags to add to the mount. :return: Mount. """ if path is not None and app is not None: - mount = Mount(path, app=app, name=name) + mount = Mount(path, app=app, name=name, **tags) assert mount is not None, "Either 'path' and 'app' or 'mount' variables are needed" diff --git a/tests/test_applications.py b/tests/test_applications.py index 72a86267..e002c965 100644 --- a/tests/test_applications.py +++ b/tests/test_applications.py @@ -41,6 +41,10 @@ def middleware(self): def app(self): return Flama(schema=None, docs=None) + @pytest.fixture(scope="function") + def tags(self): + return {"tag": "foo", "list_tag": ["foo", "bar"], "dict_tag": {"foo": "bar"}} + def test_init(self, module, component): component_obj = component() @@ -120,58 +124,60 @@ def test_routes(self, app): assert routes == expected_routes - def test_add_route(self, app): + def test_add_route(self, app, tags): def foo(): ... with patch.object(app, "router", spec=Router) as router_mock: router_mock.add_route.return_value = foo - route = app.add_route("/", foo) + route = app.add_route("/", foo, **tags) assert router_mock.add_route.call_args_list == [ - call("/", foo, methods=None, name=None, include_in_schema=True, route=None, root=app) + call("/", foo, methods=None, name=None, include_in_schema=True, route=None, root=app, **tags) ] assert route == foo - def test_route(self, app): + def test_route(self, app, tags): with patch.object(app, "router", spec=Router) as router_mock: - @app.route("/") + @app.route("/", **tags) def foo(): ... assert router_mock.route.call_args_list == [ - call("/", methods=None, name=None, include_in_schema=True, root=app) + call("/", methods=None, name=None, include_in_schema=True, root=app, **tags) ] - def test_add_websocket_route(self, app): + def test_add_websocket_route(self, app, tags): def foo(): ... with patch.object(app, "router", spec=Router) as router_mock: router_mock.add_websocket_route.return_value = foo - route = app.add_websocket_route("/", foo) + route = app.add_websocket_route("/", foo, **tags) - assert router_mock.add_websocket_route.call_args_list == [call("/", foo, name=None, route=None, root=app)] + assert router_mock.add_websocket_route.call_args_list == [ + call("/", foo, name=None, route=None, root=app, **tags) + ] assert route == foo - def test_websocket_route(self, app): + def test_websocket_route(self, app, tags): with patch.object(app, "router", spec=Router) as router_mock: - @app.websocket_route("/") + @app.websocket_route("/", **tags) def foo(): ... - assert router_mock.websocket_route.call_args_list == [call("/", name=None, root=app)] + assert router_mock.websocket_route.call_args_list == [call("/", name=None, root=app, **tags)] - def test_mount(self, app): + def test_mount(self, app, tags): expected_mount = MagicMock() with patch.object(app, "router", spec=Router) as router_mock: router_mock.mount.return_value = expected_mount - mount = app.mount("/", expected_mount) + mount = app.mount("/", expected_mount, **tags) - assert router_mock.mount.call_args_list == [call("/", expected_mount, name=None, mount=None, root=app)] + assert router_mock.mount.call_args_list == [call("/", expected_mount, name=None, mount=None, root=app, **tags)] assert mount == expected_mount def test_injector(self, app): diff --git a/tests/test_routing.py b/tests/test_routing.py index f64205f1..02498c75 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -27,13 +27,16 @@ def foo(): ... app = EndpointWrapper(foo, EndpointWrapper.type.http) - route = BaseRoute("/", app, name="foo", include_in_schema=False) + route = BaseRoute( + "/", app, name="foo", include_in_schema=False, tag="tag", list_tag=["foo", "bar"], dict_tag={"foo": "bar"} + ) assert route.path == url.RegexPath("/") assert route.app == app assert route.endpoint == foo assert route.name == "foo" assert route.include_in_schema is False + assert route.tags == {"tag": "tag", "list_tag": ["foo", "bar"], "dict_tag": {"foo": "bar"}} @pytest.mark.skipif( sys.version_info < (3, 8), reason="requires python3.8 or higher to use async mocks" @@ -466,6 +469,10 @@ def app_mock(self): def component_mock(self): return MagicMock(spec=Component) + @pytest.fixture(scope="function") + def tags(self): + return {"tag": "foo", "list_tag": ["foo", "bar"], "dict_tag": {"foo": "bar"}} + def test_init(self, app_mock): with patch("flama.routing.Router.build") as method_mock: Router([], root=app_mock) @@ -529,28 +536,30 @@ def test_add_component(self, router, component_mock): assert router.components == [component_mock] - def test_add_route_function(self, router): + def test_add_route_function(self, router, tags): async def foo(): return "foo" - router.add_route("/", foo) + router.add_route("/", foo, **tags) assert len(router.routes) == 1 assert isinstance(router.routes[0], Route) assert router.routes[0].path == "/" assert router.routes[0].endpoint == foo + assert router.routes[0].tags == tags - def test_add_route_endpoint(self, router): + def test_add_route_endpoint(self, router, tags): class FooEndpoint(endpoints.HTTPEndpoint): async def get(self): return "foo" - router.add_route("/", FooEndpoint) + router.add_route("/", FooEndpoint, **tags) assert len(router.routes) == 1 assert isinstance(router.routes[0], Route) assert router.routes[0].path == "/" assert router.routes[0].endpoint == FooEndpoint + assert router.routes[0].tags == tags def test_add_route_wrong_params(self, router): with pytest.raises(AssertionError, match="Either 'path' and 'endpoint' or 'route' variables are needed"): @@ -563,8 +572,8 @@ class Foo: with pytest.raises(AssertionError, match="Endpoint must be a callable or an HTTPEndpoint subclass"): router.add_route(path="/", endpoint=Foo) - def test_route_function(self, router): - @router.route("/") + def test_route_function(self, router, tags): + @router.route("/", **tags) async def foo(): return "foo" @@ -572,9 +581,10 @@ async def foo(): assert isinstance(router.routes[0], Route) assert router.routes[0].path == "/" assert router.routes[0].endpoint == foo + assert router.routes[0].tags == tags - def test_route_endpoint(self, router): - @router.route("/") + def test_route_endpoint(self, router, tags): + @router.route("/", **tags) class FooEndpoint(endpoints.HTTPEndpoint): async def get(self): return "foo" @@ -583,6 +593,7 @@ async def get(self): assert isinstance(router.routes[0], Route) assert router.routes[0].path == "/" assert router.routes[0].endpoint == FooEndpoint + assert router.routes[0].tags == tags def test_route_wrong_endpoint(self, router): with pytest.raises(AssertionError, match="Endpoint must be a callable or an HTTPEndpoint subclass"): @@ -591,28 +602,30 @@ def test_route_wrong_endpoint(self, router): class Foo: ... - def test_add_websocket_route_function(self, router): + def test_add_websocket_route_function(self, router, tags): async def foo(): return "foo" - router.add_websocket_route("/", foo) + router.add_websocket_route("/", foo, **tags) assert len(router.routes) == 1 assert isinstance(router.routes[0], WebSocketRoute) assert router.routes[0].path == "/" assert router.routes[0].endpoint == foo + assert router.routes[0].tags == tags - def test_add_websocket_route_endpoint(self, router): + def test_add_websocket_route_endpoint(self, router, tags): class FooEndpoint(endpoints.WebSocketEndpoint): async def on_receive(self, websocket): return "foo" - router.add_websocket_route("/", FooEndpoint) + router.add_websocket_route("/", FooEndpoint, **tags) assert len(router.routes) == 1 assert isinstance(router.routes[0], WebSocketRoute) assert router.routes[0].path == "/" assert router.routes[0].endpoint == FooEndpoint + assert router.routes[0].tags == tags def test_add_websocket_route_wrong_params(self, router): with pytest.raises(AssertionError, match="Either 'path' and 'endpoint' or 'route' variables are needed"): @@ -625,8 +638,8 @@ class Foo: with pytest.raises(AssertionError, match="Endpoint must be a callable or a WebSocketEndpoint subclass"): router.add_websocket_route(path="/", endpoint=Foo) - def test_websocket_route_function(self, router): - @router.websocket_route("/") + def test_websocket_route_function(self, router, tags): + @router.websocket_route("/", **tags) async def foo(): return "foo" @@ -634,9 +647,10 @@ async def foo(): assert isinstance(router.routes[0], WebSocketRoute) assert router.routes[0].path == "/" assert router.routes[0].endpoint == foo + assert router.routes[0].tags == tags - def test_websocket_route_endpoint(self, router): - @router.websocket_route("/") + def test_websocket_route_endpoint(self, router, tags): + @router.websocket_route("/", **tags) class FooEndpoint(endpoints.WebSocketEndpoint): async def on_receive(self, websocket): return "foo" @@ -645,6 +659,7 @@ async def on_receive(self, websocket): assert isinstance(router.routes[0], WebSocketRoute) assert router.routes[0].path == "/" assert router.routes[0].endpoint == FooEndpoint + assert router.routes[0].tags == tags def test_websocket_route_wrong_endpoint(self, router): with pytest.raises(AssertionError, match="Endpoint must be a callable or a WebSocketEndpoint subclass"): @@ -653,31 +668,33 @@ def test_websocket_route_wrong_endpoint(self, router): class Foo: ... - def test_mount_app(self, app, app_mock): - app.mount("/app/", app=app_mock) + def test_mount_app(self, app, app_mock, tags): + app.mount("/app/", app=app_mock, **tags) assert len(app.routes) == 1 assert isinstance(app.routes[0], Mount) assert app.routes[0].path == "/app" assert app.routes[0].app == app_mock + assert app.routes[0].tags == tags - def test_mount_router(self, app, component_mock): + def test_mount_router(self, app, component_mock, tags): router = Router(components=[component_mock]) - app.mount("/app/", app=router) + app.mount("/app/", app=router, **tags) assert len(app.router.routes) == 1 # Check mount is initialized assert isinstance(app.routes[0], Mount) mount_route = app.router.routes[0] assert mount_route.path == "/app" + assert mount_route.tags == tags # Check router is created and initialized, also shares components and modules with main app assert isinstance(mount_route.app, Router) mount_router = mount_route.app assert mount_router.components == Components([component_mock]) assert app.components == Components([component_mock]) - def test_mount_declarative(self, component_mock): + def test_mount_declarative(self, component_mock, tags): def root(): ... @@ -688,11 +705,12 @@ def foo_view(): ... routes = [ - Route("/", root), + Route("/", root, **tags), Mount( "/foo", routes=[Route("/", foo, methods=["GET"]), Route("/view", foo_view, methods=["GET"])], components=[component_mock], + **tags ), Mount( "/bar", @@ -700,6 +718,7 @@ def foo_view(): routes=[Route("/", foo, methods=["GET"]), Route("/view", foo_view, methods=["GET"])], components=[component_mock], ), + **tags ), ] @@ -711,10 +730,13 @@ def foo_view(): assert isinstance(app.router.routes[0], Route) root_route = app.router.routes[0] assert root_route.path == "/" + assert root_route.tags == tags # Check mount with routes is initialized assert isinstance(app.router.routes[1], Mount) mount_with_routes_route = app.router.routes[1] + assert mount_with_routes_route.path == "/foo" + assert mount_with_routes_route.tags == tags # Check router is created and initialized, also shares components and modules with main app assert isinstance(mount_with_routes_route.app, Router) mount_with_routes_router = mount_with_routes_route.app @@ -729,6 +751,8 @@ def foo_view(): # Check mount with app is initialized assert isinstance(app.router.routes[2], Mount) mount_with_app_route = app.router.routes[2] + assert mount_with_app_route.path == "/bar" + assert mount_with_app_route.tags == tags # Check router is created and initialized, also shares components and modules with main app assert isinstance(mount_with_app_route.app, Router) mount_with_app_router = mount_with_app_route.app