There was a recent question on the Discord server about paths and trailing slashes. This will hopefully be a bit of an explanation about how this works. The new router mirrors the functionality from the old one, and corrects a few edge cases that had incorrect results.
There are really two cases to consider:
- A root level trailing slash:
https://sanicframework.org/
- A path level trailing slash:
https://sanicframework.org/en/
According to the HTTP spec, the first URL is is same with or without a slash. The second is different without the slash, although most of the Internet now treats it the same. However, since technically a URL is meant to point at a file path: /en
means a file called en
and /en/
means a directory called en
.
But, again, much of the Internet and APIs generally treat them as equivalent. Therefore in Sanic, the default is to keep them the same.
This brings up the question of strict_slashes
, how they work, and how different calls will be handled in Sanic.
Since you can define a route either with or without the slash, with or without strict_slashes
, and either as a root or path level: there are 8 possibilities we need to consider:
Route | Strict Slashes |
---|---|
"" |
True |
"" |
False |
"/" |
True |
"/" |
False |
"/foo" |
True |
"/foo" |
False |
"/foo/" |
True |
"/foo/" |
False |
However, not all of these can coexist since having eight handlers would lead to ambiguity.
Let’s setup these routes and see what happens:
from sanic import Sanic, text
app = Sanic("pathtest")
paths = (
("", True),
# ("", False), << Same as previous, so we cannot use both
# ("/", True), << Same as previous, so we cannot use both
# ("/", False), << Same as previous, so we cannot use both
("/foo", True),
# ("/foo", False), << Same as previous, so we cannot use both
("/foo/", True),
# ("/foo/", False), << Same as previous, so we cannot use both
)
for idx, (path, strict) in enumerate(paths):
@app.get(path, strict_slashes=strict, name=f"path{idx}")
def handler(request):
return text(
f"{request.name}: {request.path=} {request.route.path=} {request.route.parts=}"
)
app.run(port=9999, debug=True)
As you can see, there really are only three true options. Thinking from the client perspective:
- localhost:9999 or localhost:9999/ (they are the same)
- localhost:9999/foo
- localhost:9999/foo/
Therefore, when we try to hit these endpoints, it should look like this:
$ curl localhost:9999 localhost:9999/ localhost:9999/foo localhost:9999/foo/
pathtest.path0: request.path='/' request.route.path='' request.route.parts=('',)
pathtest.path0: request.path='/' request.route.path='' request.route.parts=('',)
pathtest.path1: request.path='/foo' request.route.path='foo' request.route.parts=('foo',)
pathtest.path2: request.path='/foo/' request.route.path='foo/' request.route.parts=('foo', '')
Sanic retains the path information from the request because it is important and potentially deterministic. Notice how the first two are identical?
The way that Sanic achieves that in the router is basically by ignoring the beginning slash. That is way request.route.path
(what the router uses) is not the same as request.path
what came in from the HTTP request.
For the sake of completion… Here is another acceptable groupings with strict_slashes
.
paths = (
# ("", True),
("", False),
# ("/", True),
# ("/", False),
# ("/foo", True),
("/foo", False),
("/foo/", True),
# ("/foo/", False),
)
$ curl localhost:9999 localhost:9999/ localhost:9999/foo localhost:9999/foo/
pathtest.path0: request.path='/' request.route.path='' request.route.parts=('',)
pathtest.path0: request.path='/' request.route.path='' request.route.parts=('',)
pathtest.path1: request.path='/foo' request.route.path='foo' request.route.parts=('foo',)
pathtest.path2: request.path='/foo/' request.route.path='foo/' request.route.parts=('foo', '
Therefore:
strict_slashes
on a non-root path means:
-
True
: accept only as defined-
/foo
matches ONLY/foo
-
/foo/
matches ONLY/foo/
-
-
False
: match either case-
/foo
matches/foo
or/foo/
-
/foo/
matches/foo
or/foo/
-
One final note:
The reason the router “ignores” the first slash is because it is not important and not deterministic. The more “public” API of request.path
displays it since it is part of the HTTP request. But, since it just gets in the way for the router, we ignore it there. In 99% of use cases when you need the path: use request.path
. Furthermore, request.route.path
would display the route as defined including any parameters: <foo>
.