Repository: litestar-org/litestar

Issue:
allow empty `""` path in Controller ?
https://github.com/starlite-api/starlite/blob/965d97620774991bd302a3a227bd3ebe6236ae96/starlite/controller.py#L27-L28

### Context
In some of our project (built with FastAPI) we use **Factory** classes that helps us creating bunch of endpoints for given user inputs and dependencies (https://developmentseed.org/titiler/advanced/tiler_factories/ https://developmentseed.org/titiler/api/titiler/core/factory/#tilerfactory). This is why I was really interested in the `Controller` class to replace our `Factories`. 

One problem I'm facing right now is that Controller `Path` has to be non null. 

```python
from starlite import get, Starlite
from starlite.controller import Controller
from starlette.requests import Request


class Endpoints(Controller):
    """TileMatrixSets endpoints."""

    path = "/"

    @get(path="/yo")
    def hey(self, request: Request) -> str:
        return "yipi"

    @get(path="ye")
    def hey2(self, request: Request) -> str:
        return "what"


app = Starlite(route_handlers=[Endpoints])
```
schema

<details>

```json
{
  "openapi": "3.1.0",
  "info": {
    "title": "Starlite API",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "/"
    }
  ],
  "paths": {
    "//yo": {
      "get": {
        "operationId": "hey",
        "responses": {
          "200": {
            "description": "Request fulfilled, document follows",
            "headers": {},
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "deprecated": false
      }
    },
    "//ye": {
      "get": {
        "operationId": "hey2",
        "responses": {
          "200": {
            "description": "Request fulfilled, document follows",
            "headers": {},
            "content": {
              "application/json": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        },
        "deprecated": false
      }
    }
  }
}
```

</details>

as we can see in the schema ☝️, both endpoints are `//` prefixed because Starlite is normalizing path (I think).  I can agree that I want to use the Controller class for something it wasn't designed for, but it could be nice if the `path` could be set to `""`.

### Potential fix

By allowing path to be all but not `None` it seem to fix my issue, but I'm not quite sure what it means for the rest of the application.

```python
class Endpoints(Controller):
    """TileMatrixSets endpoints."""

    path = ""

    def __init__(self, owner: "Router"):
        if not hasattr(self, "path") or self.path is None:
            raise ImproperlyConfiguredException("Controller subclasses must set a path attribute")

        for key in ["dependencies", "response_headers", "response_class", "guards"]:
            if not hasattr(self, key):
                setattr(self, key, None)

        self.path = self.path
        self.owner = owner
        for route_handler in self.get_route_handlers():
            route_handler.owner = self

    @get(path="/yo")
    def hey(self, request: Request) -> str:
        print(request)
        return "yipi"

    @get(path="/ye")
    def hey2(self, request: Request) -> str:
        print(request)
        return "what"
```

again, I will understand that I want to use the Controller class outside of its scope 🙏 



Discussion:
Hiya. 

If I understand you correctly you would.in effect like to use a controller on the root level, correct?

If that is the case then I'd say we have a bug - you should be able to do:

`path = "/"`

If this results in //, we need to fix it.

If I misunderstood though:

How about this solution: 

1. We move the validation to a method that is called by `Controller.__init__`. 
2. You can then override it in your subclass.

In either case, would you like to add a PR with a fix (and changelog update)?
> If I understand you correctly you would.in effect like to use a controller on the root level, correct?

Yes. 

> If this results in //, we need to fix it.

It does (see schema) 

> In either case, would you like to add a PR with a fix (and changelog update)?

I can try, later today or tomorrow 😄 


@Goldziher I'm not quite sure of the solution here.

## Case 1: `path = ""`

This is quite straight forward, meaning that routes won't have any prefix. This also ensure the user set `""` as the path and that we will only raise is path is not set at all. We could just add a note in the documentation (or a warning in the code) that `""` path will append routes at the root.

## Case 2: `path = "/"` 

This could cause some misunderstanding. To me all routes should be prefixed with `/` (I believe `//yo` is a valid path 🤷. 

Sure, path 1 is good. Just add a note on the docs as well.

Maybe raise an informative error if the path is set to "/" by the user, or also allow that case. 

Resolving patch:
diff --git a/starlite/controller.py b/starlite/controller.py
--- a/starlite/controller.py
+++ b/starlite/controller.py
@@ -41,7 +41,7 @@ class Controller:
     after_request: Optional[AFTER_REQUEST_HANDLER]
 
     def __init__(self, owner: "Router"):
-        if not hasattr(self, "path") or not self.path:
+        if not hasattr(self, "path") or self.path is None:
             raise ImproperlyConfiguredException("Controller subclasses must set a path attribute")
 
         for key in [
@@ -55,7 +55,9 @@ def __init__(self, owner: "Router"):
             if not hasattr(self, key):
                 setattr(self, key, None)
 
-        self.path = normalize_path(self.path)
+        if self.path:
+            self.path = normalize_path(self.path)
+
         self.owner = owner
         for route_handler in self.get_route_handlers():
             route_handler.owner = self


Test patch:
diff --git a/tests/test_controller.py b/tests/test_controller.py
--- a/tests/test_controller.py
+++ b/tests/test_controller.py
@@ -7,6 +7,7 @@
     HTTP_200_OK,
     HTTP_201_CREATED,
     HTTP_204_NO_CONTENT,
+    HTTP_404_NOT_FOUND,
     HTTP_500_INTERNAL_SERVER_ERROR,
 )
 
@@ -275,3 +276,59 @@ async def ws(self, socket: WebSocket) -> None:
         ws.send_json({"data": "123"})
         data = ws.receive_json()
         assert data
+
+
+@pytest.mark.parametrize(
+    "decorator, http_method, expected_status_code, test_path",
+    [
+        (get, HttpMethod.GET, HTTP_200_OK, ""),
+        (post, HttpMethod.POST, HTTP_201_CREATED, ""),
+        (put, HttpMethod.PUT, HTTP_200_OK, ""),
+        (patch, HttpMethod.PATCH, HTTP_200_OK, ""),
+        (delete, HttpMethod.DELETE, HTTP_204_NO_CONTENT, ""),
+        (get, HttpMethod.GET, HTTP_200_OK, "/"),
+        (post, HttpMethod.POST, HTTP_201_CREATED, "/"),
+        (put, HttpMethod.PUT, HTTP_200_OK, "/"),
+        (patch, HttpMethod.PATCH, HTTP_200_OK, "/"),
+        (delete, HttpMethod.DELETE, HTTP_204_NO_CONTENT, "/"),
+    ],
+)
+def test_controller_empty_path(decorator, http_method, expected_status_code, test_path):
+    class MyController(Controller):
+        path = test_path
+
+        @decorator(path="/something")
+        def test_method(self) -> Person:
+            return person_instance
+
+    with create_test_client(MyController) as client:
+        response = client.request(http_method, test_path + "/something")
+        assert response.status_code == expected_status_code
+        assert response.json() == person_instance.dict()
+
+
+@pytest.mark.parametrize(
+    "decorator, http_method, expected_status_code",
+    [
+        (get, HttpMethod.GET, HTTP_200_OK),
+        (post, HttpMethod.POST, HTTP_201_CREATED),
+        (put, HttpMethod.PUT, HTTP_200_OK),
+        (patch, HttpMethod.PATCH, HTTP_200_OK),
+        (delete, HttpMethod.DELETE, HTTP_204_NO_CONTENT),
+    ],
+)
+def test_controller_empty_path_root_endpoint(decorator, http_method, expected_status_code):
+    class MyController(Controller):
+        path = ""
+
+        @decorator(path="/")
+        def test_method(self) -> Person:
+            return person_instance
+
+    with create_test_client(MyController) as client:
+        response = client.request(http_method, "/")
+        assert response.status_code == expected_status_code
+        assert response.json() == person_instance.dict()
+
+        response = client.request(http_method, "")
+        assert response.status_code == HTTP_404_NOT_FOUND