Repository: sanic-org/sanic

Issue:
Inconsistent slashes in Blueprint
**Describe the bug**
There is an inconsistency with how slashes are handled in blueprints when `strict_slashes` applied in different manner.



**Code snippet**
```python
@app.before_server_start
async def display(_):
    for route in app.router.routes:
        print(route)
```

The following yields: `<Route: name=__main__.bp.index path=foo/>`

This is correct :heavy_check_mark: 

```python
bp = Blueprint("bp", url_prefix="/foo/", strict_slashes=True)

@bp.get("/")
async def handler(_):
    ...
```

And this yields: `<Route: name=__main__.bp.index path=foo//>`

This is incorrect :x:

```python
@bp.get("/")
async def index(_):
    ...


app.blueprint(bp, url_prefix="/foo/", strict_slashes=True)
```

**Expected behavior**
Applying prefix with `static_slashes` in `app.blueprint` is the same as in `Blueprint`



Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) Report
> Merging [#2515](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) (ee6776d) into [main](https://codecov.io/gh/sanic-org/sanic/commit/9cf38a0a837bedb441c6a9fe538932d58f6adfec?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) (9cf38a0) will **decrease** coverage by `0.024%`.
> The diff coverage is `66.666%`.

> :exclamation: Current head ee6776d differs from pull request most recent head d500d35. Consider uploading reports for the commit d500d35 to get more accurate results

```diff
@@              Coverage Diff              @@
##              main     #2515       +/-   ##
=============================================
- Coverage   87.392%   87.367%   -0.025%     
=============================================
  Files           69        69               
  Lines         5560      5565        +5     
  Branches       966       968        +2     
=============================================
+ Hits          4859      4862        +3     
- Misses         509       510        +1     
- Partials       192       193        +1     
```


| [Impacted Files](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) | Coverage Δ | |
|---|---|---|
| [sanic/blueprints.py](https://codecov.io/gh/sanic-org/sanic/pull/2515/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org#diff-c2FuaWMvYmx1ZXByaW50cy5weQ==) | `90.000% <66.666%> (-0.770%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org). Last update [9cf38a0...d500d35](https://codecov.io/gh/sanic-org/sanic/pull/2515?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org).


Patch:
diff --git a/sanic/blueprints.py b/sanic/blueprints.py
index 2b9e52e0..271306cb 100644
--- a/sanic/blueprints.py
+++ b/sanic/blueprints.py
@@ -308,7 +308,7 @@ class Blueprint(BaseSanic):
             # prefixed properly in the router
             future.handler.__blueprintname__ = self.name
             # Prepend the blueprint URI prefix if available
-            uri = url_prefix + future.uri if url_prefix else future.uri
+            uri = self._setup_uri(future.uri, url_prefix)
 
             version_prefix = self.version_prefix
             for prefix in (
@@ -333,7 +333,7 @@ class Blueprint(BaseSanic):
 
             apply_route = FutureRoute(
                 future.handler,
-                uri[1:] if uri.startswith("//") else uri,
+                uri,
                 future.methods,
                 host,
                 strict_slashes,
@@ -363,7 +363,7 @@ class Blueprint(BaseSanic):
         # Static Files
         for future in self._future_statics:
             # Prepend the blueprint URI prefix if available
-            uri = url_prefix + future.uri if url_prefix else future.uri
+            uri = self._setup_uri(future.uri, url_prefix)
             apply_route = FutureStatic(uri, *future[1:])
 
             if (self, apply_route) in app._future_registry:
@@ -456,6 +456,18 @@ class Blueprint(BaseSanic):
                 break
         return value
 
+    @staticmethod
+    def _setup_uri(base: str, prefix: Optional[str]):
+        uri = base
+        if prefix:
+            uri = prefix
+            if base.startswith("/") and prefix.endswith("/"):
+                uri += base[1:]
+            else:
+                uri += base
+
+        return uri[1:] if uri.startswith("//") else uri
+
     @staticmethod
     def register_futures(
         apps: Set[Sanic], bp: Blueprint, futures: Sequence[Tuple[Any, ...]]
diff --git a/sanic/mixins/routes.py b/sanic/mixins/routes.py
index 73180109..4f619714 100644
--- a/sanic/mixins/routes.py
+++ b/sanic/mixins/routes.py
@@ -958,6 +958,7 @@ class RouteMixin(metaclass=SanicMeta):
         # serve from the folder
         if not static.resource_type:
             if not path.isfile(file_or_directory):
+                uri = uri.rstrip("/")
                 uri += "/<__file_uri__:path>"
         elif static.resource_type == "dir":
             if path.isfile(file_or_directory):
@@ -965,6 +966,7 @@ class RouteMixin(metaclass=SanicMeta):
                     "Resource type improperly identified as directory. "
                     f"'{file_or_directory}'"
                 )
+            uri = uri.rstrip("/")
             uri += "/<__file_uri__:path>"
         elif static.resource_type == "file" and not path.isfile(
             file_or_directory


Test patch:
diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py
index 8cf72e09..cf4e7a3d 100644
--- a/tests/test_blueprints.py
+++ b/tests/test_blueprints.py
@@ -17,7 +17,7 @@ from sanic.response import json, text
 # ------------------------------------------------------------ #
 
 
-def test_bp(app):
+def test_bp(app: Sanic):
     bp = Blueprint("test_text")
 
     @bp.route("/")
@@ -30,7 +30,7 @@ def test_bp(app):
     assert response.text == "Hello"
 
 
-def test_bp_app_access(app):
+def test_bp_app_access(app: Sanic):
     bp = Blueprint("test")
 
     with pytest.raises(
@@ -87,7 +87,7 @@ def test_versioned_routes_get(app, method):
     assert response.status == 200
 
 
-def test_bp_strict_slash(app):
+def test_bp_strict_slash(app: Sanic):
     bp = Blueprint("test_text")
 
     @bp.get("/get", strict_slashes=True)
@@ -114,7 +114,7 @@ def test_bp_strict_slash(app):
     assert response.status == 404
 
 
-def test_bp_strict_slash_default_value(app):
+def test_bp_strict_slash_default_value(app: Sanic):
     bp = Blueprint("test_text", strict_slashes=True)
 
     @bp.get("/get")
@@ -134,7 +134,7 @@ def test_bp_strict_slash_default_value(app):
     assert response.status == 404
 
 
-def test_bp_strict_slash_without_passing_default_value(app):
+def test_bp_strict_slash_without_passing_default_value(app: Sanic):
     bp = Blueprint("test_text")
 
     @bp.get("/get")
@@ -154,7 +154,7 @@ def test_bp_strict_slash_without_passing_default_value(app):
     assert response.text == "OK"
 
 
-def test_bp_strict_slash_default_value_can_be_overwritten(app):
+def test_bp_strict_slash_default_value_can_be_overwritten(app: Sanic):
     bp = Blueprint("test_text", strict_slashes=True)
 
     @bp.get("/get", strict_slashes=False)
@@ -174,7 +174,7 @@ def test_bp_strict_slash_default_value_can_be_overwritten(app):
     assert response.text == "OK"
 
 
-def test_bp_with_url_prefix(app):
+def test_bp_with_url_prefix(app: Sanic):
     bp = Blueprint("test_text", url_prefix="/test1")
 
     @bp.route("/")
@@ -187,7 +187,7 @@ def test_bp_with_url_prefix(app):
     assert response.text == "Hello"
 
 
-def test_several_bp_with_url_prefix(app):
+def test_several_bp_with_url_prefix(app: Sanic):
     bp = Blueprint("test_text", url_prefix="/test1")
     bp2 = Blueprint("test_text2", url_prefix="/test2")
 
@@ -208,7 +208,7 @@ def test_several_bp_with_url_prefix(app):
     assert response.text == "Hello2"
 
 
-def test_bp_with_host(app):
+def test_bp_with_host(app: Sanic):
     bp = Blueprint("test_bp_host", url_prefix="/test1", host="example.com")
 
     @bp.route("/")
@@ -230,7 +230,7 @@ def test_bp_with_host(app):
     assert response.body == b"Hello subdomain!"
 
 
-def test_several_bp_with_host(app):
+def test_several_bp_with_host(app: Sanic):
     bp = Blueprint(
         "test_text",
         url_prefix="/test",
@@ -274,7 +274,7 @@ def test_several_bp_with_host(app):
     assert response.text == "Hello3"
 
 
-def test_bp_with_host_list(app):
+def test_bp_with_host_list(app: Sanic):
     bp = Blueprint(
         "test_bp_host",
         url_prefix="/test1",
@@ -304,7 +304,7 @@ def test_bp_with_host_list(app):
     assert response.text == "Hello subdomain!"
 
 
-def test_several_bp_with_host_list(app):
+def test_several_bp_with_host_list(app: Sanic):
     bp = Blueprint(
         "test_text",
         url_prefix="/test",
@@ -356,7 +356,7 @@ def test_several_bp_with_host_list(app):
     assert response.text == "Hello3"
 
 
-def test_bp_middleware(app):
+def test_bp_middleware(app: Sanic):
     blueprint = Blueprint("test_bp_middleware")
 
     @blueprint.middleware("response")
@@ -375,7 +375,7 @@ def test_bp_middleware(app):
     assert response.text == "FAIL"
 
 
-def test_bp_middleware_with_route(app):
+def test_bp_middleware_with_route(app: Sanic):
     blueprint = Blueprint("test_bp_middleware")
 
     @blueprint.middleware("response")
@@ -398,7 +398,7 @@ def test_bp_middleware_with_route(app):
     assert response.text == "OK"
 
 
-def test_bp_middleware_order(app):
+def test_bp_middleware_order(app: Sanic):
     blueprint = Blueprint("test_bp_middleware_order")
     order = []
 
@@ -438,7 +438,7 @@ def test_bp_middleware_order(app):
     assert order == [1, 2, 3, 4, 5, 6]
 
 
-def test_bp_exception_handler(app):
+def test_bp_exception_handler(app: Sanic):
     blueprint = Blueprint("test_middleware")
 
     @blueprint.route("/1")
@@ -470,7 +470,7 @@ def test_bp_exception_handler(app):
     assert response.status == 200
 
 
-def test_bp_exception_handler_applied(app):
+def test_bp_exception_handler_applied(app: Sanic):
     class Error(Exception):
         pass
 
@@ -500,7 +500,7 @@ def test_bp_exception_handler_applied(app):
     assert response.status == 500
 
 
-def test_bp_exception_handler_not_applied(app):
+def test_bp_exception_handler_not_applied(app: Sanic):
     class Error(Exception):
         pass
 
@@ -522,7 +522,7 @@ def test_bp_exception_handler_not_applied(app):
     assert response.status == 500
 
 
-def test_bp_listeners(app):
+def test_bp_listeners(app: Sanic):
     app.route("/")(lambda x: x)
     blueprint = Blueprint("test_middleware")
 
@@ -559,7 +559,7 @@ def test_bp_listeners(app):
     assert order == [1, 2, 3, 4, 5, 6]
 
 
-def test_bp_static(app):
+def test_bp_static(app: Sanic):
     current_file = inspect.getfile(inspect.currentframe())
     with open(current_file, "rb") as file:
         current_file_contents = file.read()
@@ -597,7 +597,7 @@ def test_bp_static_content_type(app, file_name):
     assert response.headers["Content-Type"] == "text/html; charset=utf-8"
 
 
-def test_bp_shorthand(app):
+def test_bp_shorthand(app: Sanic):
     blueprint = Blueprint("test_shorhand_routes")
     ev = asyncio.Event()
 
@@ -682,7 +682,7 @@ def test_bp_shorthand(app):
     assert ev.is_set()
 
 
-def test_bp_group(app):
+def test_bp_group(app: Sanic):
     deep_0 = Blueprint("deep_0", url_prefix="/deep")
     deep_1 = Blueprint("deep_1", url_prefix="/deep1")
 
@@ -722,7 +722,7 @@ def test_bp_group(app):
     assert response.text == "D1B_OK"
 
 
-def test_bp_group_with_default_url_prefix(app):
+def test_bp_group_with_default_url_prefix(app: Sanic):
     from sanic.response import json
 
     bp_resources = Blueprint("bp_resources")
@@ -873,7 +873,7 @@ def test_websocket_route(app: Sanic):
     assert event.is_set()
 
 
-def test_duplicate_blueprint(app):
+def test_duplicate_blueprint(app: Sanic):
     bp_name = "bp"
     bp = Blueprint(bp_name)
     bp1 = Blueprint(bp_name)
@@ -1056,7 +1056,7 @@ def test_bp_set_attribute_warning():
         bp.foo = 1
 
 
-def test_early_registration(app):
+def test_early_registration(app: Sanic):
     assert len(app.router.routes) == 0
 
     bp = Blueprint("bp")
@@ -1082,3 +1082,29 @@ def test_early_registration(app):
     for path in ("one", "two", "three"):
         _, response = app.test_client.get(f"/{path}")
         assert response.text == path
+
+
+def test_remove_double_slashes_defined_on_bp(app: Sanic):
+    bp = Blueprint("bp", url_prefix="/foo/", strict_slashes=True)
+
+    @bp.get("/")
+    async def handler(_):
+        ...
+
+    app.blueprint(bp)
+    app.router.finalize()
+
+    assert app.router.routes[0].path == "foo/"
+
+
+def test_remove_double_slashes_defined_on_register(app: Sanic):
+    bp = Blueprint("bp")
+
+    @bp.get("/")
+    async def index(_):
+        ...
+
+    app.blueprint(bp, url_prefix="/foo/", strict_slashes=True)
+    app.router.finalize()
+
+    assert app.router.routes[0].path == "foo/"