question-mark
Stuck on an issue?

Lightrun Answers was designed to reduce the constant googling that comes with debugging 3rd party libraries. It collects links to all the places you might be looking at while hunting down a tough bug.

And, if you’re still stuck at the end, we’re happy to hop on a call to see how we can help out.

Handling trailing slashes properly with mounted routes

See original GitHub issue

Hello there!

In the documentation, there is this example about large applications. If we make it a little more complete, we can get something like this:

import uvicorn
from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Mount, Route


async def homepage(request):
    return JSONResponse(status_code=200)


async def users(request):
    return JSONResponse([{"username": "bob"}, {"username": "alice"}])


async def user(request):
    username = request.path_params["username"]
    if username == "bob":
        return JSONResponse({"username": "bob"})
    if username == "alice":
        return JSONResponse({"username": "alice"})
    return JSONResponse(status_code=404)


routes = [
    Route('/', homepage),
    Mount('/users', routes=[
        Route('/', users, methods=['GET', 'POST']),
        Route('/{username}', user),
    ])
]

app = Starlette(routes=routes)
uvicorn.run(app)

But the problem with this example is that all routes are behaving differently:

  • curl localhost:8000 answers as expected
  • curl localhost:8000/ answers as expected
  • curl localhost:8000/users emits a 307
  • curl localhost:8000/users/ answers as expected
  • curl localhost:8000/users/bob answers as expected
  • curl localhost:8000/users/bob/ emits a 307

So one route answers as expected whether you give it a trailing slash or not, another one only answers directly if you give it the trailing slash, and another one only answers directly if you do not give it the trailing slash.

Of course, the fact that the homepage is answering with or without slash is only due to how HTTP works, because you can’t send a GET without a route, and all HTTP clients will convert this to GET /.

At this point I’m just paraphrasing #823, where the discussion ended with:

That’s intentional, because a mount point should always be in the form /path-to-mount/{...}

Alright, let’s take that for a fact, but then… what if I want all my mounted routes to answer without a trailing slash? This is particularly important for API development, where you want to provide consistent endpoints.

I started by trying to give an empty route, i.e. Route('', users, methods=['GET', 'POST']), but that simply does not work: AssertionError: Routed paths must start with '/'.

Reconsidering the answer in #823, I then tried to mount on / directly, i.e. doing this, thinking that it would work around my problem:

routes = [
    Route('/', homepage),
    Mount('/', routes=[
        Route('/users', users, methods=['GET', 'POST']),
        Route('/users/{username}', user),
    ])
]

That seems weird here, because you could simply add the routes at the top level, but in my real use-case, I’m using routers that I import and mount. And that works great… until you add another Mount, that will never be resolved, because the routing stops at the first mount point with a matching prefix, as explained in https://github.com/encode/starlette/issues/380#issuecomment-462257965.

Then I saw https://github.com/encode/starlette/issues/633#issuecomment-530595163, and tried to add /? everywhere, just like this:

routes = [
    Route('/', homepage),
    Mount('/users', routes=[
        Route('/?', users, methods=['GET', 'POST']),
        Route('/{username}/?', user),
    ])
]

This is not user-friendly at all, but it helped on the /users/{username} route, which now answers as expected whether you give it a trailing slash or not. Alas, the /users route still emits a 307. So basically, at this point, I just don’t know how to handle this, and I’m pretty frustrated by spending so much time on such a trivial thing.

What I ended up doing, since I’m using routers, is defining the whole path in my routes, with a big hack at the application level:

routes = (
    users_router.routes,
    + other_router.routes
)

If there isn’t something obvious that I missed and would resolve my problem in a simpler way, please consider one or more of the following (by my preference order):

  • Making all routes accessible without redirection with or without the trailing slash
  • “Denormalizing” all routes when they’re mounted, so that you only have a list of all paths and do not stop on the first mount point that matches (basically what my hack is doing)
  • Making the developer in complete control of trailing slashes by not emitting any implicit 307
  • Having this behavior configurable and documented
  • Handling empty routes
  • Handling /?

Sorry for the long text, I couldn’t find a simpler way to expose the whole problem and thinking behind it.

Issue Analytics

  • State:open
  • Created 4 years ago
  • Reactions:18
  • Comments:11

github_iconTop GitHub Comments

6reactions
nilansahacommented, Aug 31, 2021

Thanks I just added the backslash to the GQL gateway config and solved my case. But this is an issue that needs to be solved in starlette itself. I have no idea why this is not picked up yet.

6reactions
cgorskicommented, Aug 30, 2021

Creating some middleware worked for me:


from starlette.requests import Request
from starlette.types import ASGIApp, Receive, Scope, Send

class GraphQLRedirect:
    def __init__(
        self,
        app: ASGIApp
    ) -> None:
        self.app = app
        
    async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
        if(scope['path'] == '/graphql'):
            scope['path'] = '/graphql/'
            scope['raw_path'] = b'/graphql/'
        await self.app(scope, receive, send)

and

app = FastAPI(redoc_url=None, docs_url=None, openapi_url=None)

app.add_middleware(GraphQLRedirect)

type_defs = load_schema_from_path("schema.graphql")
schema = make_executable_schema(
    type_defs, query, snake_case_fallback_resolvers
)

app.mount("/graphql", GraphQL(schema))

No more 307s!

Read more comments on GitHub >

github_iconTop Results From Across the Web

Trailing / in route patterns - Slim Framework
Slim treats a URL pattern with a trailing slash as different to one without. That is, /user and /user/ are different and so...
Read more >
How to make AWS API gateway to "understand" trailing slash?
What can I do so that /auth/ URL goes to the same route as /auth in the API Gateway? Technically speaking, this solves...
Read more >
URL Dispatch — The Pyramid Web Framework v2.0
The pattern used in route configuration may start with a slash character. ... a trailing slash, but requires one to match the proper...
Read more >
URL Dispatch - Actix
Path normalization and redirecting to slash-appended routes​. By normalizing it means: To add a trailing slash to the path. To replace multiple slashes...
Read more >
Routing - Phalcon Documentation
All the routing patterns must start with a forward slash character ( / ). ... A couple of rewrite rules that work very...
Read more >

github_iconTop Related Medium Post

No results found

github_iconTop Related StackOverflow Question

No results found

github_iconTroubleshoot Live Code

Lightrun enables developers to add logs, metrics and snapshots to live code - no restarts or redeploys required.
Start Free

github_iconTop Related Reddit Thread

No results found

github_iconTop Related Hackernoon Post

No results found

github_iconTop Related Tweet

No results found

github_iconTop Related Dev.to Post

No results found

github_iconTop Related Hashnode Post

No results found