Repository: litestar-org/litestar

Issue:
StaticFilesConfig and virtual directories
I'm trying to write a ``FileSystemProtocol`` to load files from the package data using [importlib_resources](https://importlib-resources.readthedocs.io/en/latest/using.html#). But because ``directories`` is defined as ``DirectoryPath``, pydantic checks if the given directories exist in the local filesystem. 

This is not generally true, especially in any kind of virtual filesystem (e.g. a zipped package). I think this condition should be relaxed to support virtual filesystems.

https://github.com/starlite-api/starlite/blob/9bb6dcd57c10a591377cf8e3a537e9292566d5b9/starlite/config/static_files.py#L32


Discussion:
I agree. Do you want to submit a PR?

Resolving patch:
diff --git a/litestar/cli/_utils.py b/litestar/cli/_utils.py
--- a/litestar/cli/_utils.py
+++ b/litestar/cli/_utils.py
@@ -355,7 +355,8 @@ def show_app_info(app: Litestar) -> None:  # pragma: no cover
     if app.static_files_config:
         static_files_configs = app.static_files_config
         static_files_info = [
-            f"path=[yellow]{static_files.path}[/] dirs=[yellow]{', '.join(map(str, static_files.directories))}[/] html_mode={_format_is_enabled(static_files.html_mode)}"
+            f"path=[yellow]{static_files.path}[/] dirs=[yellow]{', '.join(map(str, static_files.directories))}[/] "
+            f"html_mode={_format_is_enabled(static_files.html_mode)}"
             for static_files in static_files_configs
         ]
         table.add_row("Static files", "\n".join(static_files_info))
diff --git a/litestar/openapi/config.py b/litestar/openapi/config.py
--- a/litestar/openapi/config.py
+++ b/litestar/openapi/config.py
@@ -18,6 +18,7 @@
     Server,
     Tag,
 )
+from litestar.utils.path import normalize_path
 
 __all__ = ("OpenAPIConfig",)
 
@@ -94,6 +95,13 @@ class OpenAPIConfig:
     """A set of the enabled documentation sites and schema download endpoints."""
     operation_id_creator: OperationIDCreator = default_operation_id_creator
     """A callable that generates unique operation ids"""
+    path: str | None = field(default=None)
+    """Base path for the OpenAPI documentation endpoints."""
+
+    def __post_init__(self) -> None:
+        if self.path:
+            self.path = normalize_path(self.path)
+            self.openapi_controller = type("OpenAPIController", (self.openapi_controller,), {"path": self.path})
 
     def to_openapi_schema(self) -> OpenAPI:
         """Return an ``OpenAPI`` instance from the values stored in ``self``.


Test patch:
diff --git a/tests/unit/test_openapi/test_config.py b/tests/unit/test_openapi/test_config.py
--- a/tests/unit/test_openapi/test_config.py
+++ b/tests/unit/test_openapi/test_config.py
@@ -113,6 +113,16 @@ def handler_2() -> None:
     }
 
 
+def test_allows_customization_of_path() -> None:
+    app = Litestar(
+        openapi_config=OpenAPIConfig(title="my title", version="1.0.0", path="/custom_schema_path"),
+    )
+
+    assert app.openapi_config
+    assert app.openapi_config.path == "/custom_schema_path"
+    assert app.openapi_config.openapi_controller.path == "/custom_schema_path"
+
+
 def test_raises_exception_when_no_config_in_place() -> None:
     with pytest.raises(ImproperlyConfiguredException):
         Litestar(route_handlers=[], openapi_config=None).update_openapi_schema()
diff --git a/tests/unit/test_openapi/test_integration.py b/tests/unit/test_openapi/test_integration.py
--- a/tests/unit/test_openapi/test_integration.py
+++ b/tests/unit/test_openapi/test_integration.py
@@ -8,7 +8,7 @@
 from litestar import Controller, post
 from litestar.app import DEFAULT_OPENAPI_CONFIG
 from litestar.enums import OpenAPIMediaType
-from litestar.openapi import OpenAPIConfig
+from litestar.openapi import OpenAPIConfig, OpenAPIController
 from litestar.status_codes import HTTP_200_OK, HTTP_404_NOT_FOUND
 from litestar.testing import create_test_client
 
@@ -61,6 +61,63 @@ def test_openapi_json_not_allowed(person_controller: Type[Controller], pet_contr
         assert response.status_code == HTTP_404_NOT_FOUND
 
 
+def test_openapi_custom_path() -> None:
+    openapi_config = OpenAPIConfig(title="my title", version="1.0.0", path="/custom_schema_path")
+    with create_test_client([], openapi_config=openapi_config) as client:
+        response = client.get("/schema")
+        assert response.status_code == HTTP_404_NOT_FOUND
+
+        response = client.get("/custom_schema_path")
+        assert response.status_code == HTTP_200_OK
+
+        response = client.get("/custom_schema_path/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+
+def test_openapi_normalizes_custom_path() -> None:
+    openapi_config = OpenAPIConfig(title="my title", version="1.0.0", path="custom_schema_path")
+    with create_test_client([], openapi_config=openapi_config) as client:
+        response = client.get("/custom_schema_path/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+        response = client.get("/custom_schema_path/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+
+def test_openapi_custom_path_avoids_override() -> None:
+    class CustomOpenAPIController(OpenAPIController):
+        path = "/custom_docs"
+
+    openapi_config = OpenAPIConfig(title="my title", version="1.0.0", openapi_controller=CustomOpenAPIController)
+    with create_test_client([], openapi_config=openapi_config) as client:
+        response = client.get("/schema")
+        assert response.status_code == HTTP_404_NOT_FOUND
+
+        response = client.get("/custom_docs/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+        response = client.get("/custom_docs/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+
+def test_openapi_custom_path_overrides_custom_controller_path() -> None:
+    class CustomOpenAPIController(OpenAPIController):
+        path = "/custom_docs"
+
+    openapi_config = OpenAPIConfig(
+        title="my title", version="1.0.0", openapi_controller=CustomOpenAPIController, path="/override_docs_path"
+    )
+    with create_test_client([], openapi_config=openapi_config) as client:
+        response = client.get("/custom_docs")
+        assert response.status_code == HTTP_404_NOT_FOUND
+
+        response = client.get("/override_docs_path/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+        response = client.get("/override_docs_path/openapi.json")
+        assert response.status_code == HTTP_200_OK
+
+
 def test_msgspec_schema_generation() -> None:
     class Lookup(msgspec.Struct):
         id: Annotated[