starlette icon indicating copy to clipboard operation
starlette copied to clipboard

Let Request.url_for lookup routes in mounted app first

Open plankthom opened this issue 3 years ago • 5 comments

Proposed fix for https://github.com/encode/starlette/issues/814

This modifies the lookup of routes by name to first look in the routes of the mounted app before resolving routes in the root app.

Motivation

As mentioned by @jussiarpalahti in by https://github.com/encode/starlette/issues/814#issuecomment-593104008, this lookup algorithm makes more sense if the mounted sub-application is maintained as an independent module.

In that case you would not expect the sub-application to know under what name (if any) it is mounted.

With this feature, you can even mount the same application twice under different routes, without any need to configure the mount prefix as an application property.

plankthom avatar Jan 15 '22 12:01 plankthom

@plankthom Thank you for the PR. Can you please explain a bit about this?

As far as I could see this was not really a bug, this was the intended behaviour.

For doing reverse URL lookup on sub-mounted apps you'd need request.url_for('sub:<name>') and calling request.url_for(<name>) should not work.

aminalaee avatar Jan 15 '22 18:01 aminalaee

@aminalaee : I`ve added some motivation in the PR description.

A FastApi example test/trinkets.py

from fastapi import FastAPI, Request

app = FastAPI(title='Trinkets')
trinkets = [ {'name': 'nice'}, {'name': 'colorful'} ]

@app.get('/')
def info(request: Request):
    return {
        '_links: {'trinkets': {'href':  request.url_for('list_trinkets')}}
    }

@app.get('/trinket')
def list_trinkets(request: Request):
    return [
        {
            **trinket,
            '_links': {'self': {'href': request.url_for('get_trinket', trinket_name=trinket['name'])}}
        }
        for trinket in trinkets
    ]


@app.get('/trinket/{trinket_name}')
def get_trinket(trinket_name: str, request: Request):
    return next(t for t in trinkets if t['name'] == trinket_name)

test/main.py

import fastapi
import trinket

app = fastapi.FastAPI(title='Trinkets and Treasures')

@app.get('/')
def info(request: fastapi.Request):
    return { 
        'trinkets_v1' :  request.url_for('v1:list_trinkets'),
        'trinkets_v2' :  request.url_for('v2:list_trinkets')
    }

app.mount('/api/v1', app=trinket.app, name= 'v1')
app.mount('/api/v2', app=trinket.app, name= 'v2')

curl http://localhost:8000 | jq

{
  "trinkets_v1": "http://localhost:8000/api/v1/trinket",
  "trinkets_v2": "http://localhost:8000/api/v2/trinket"
}

curl curl http://localhost:8000/api/v2/trinket

[
  {
    "name": "nice",
    "_links": {
      "self": {
        "href": "http://localhost:8000/api/v2/trinket/nice"
      }
    }
  },
  {
    "name": "colorful",
    "_links": {
      "self": {
        "href": "http://localhost:8000/api/v2/trinket/colorful"
      }
    }
  }
]

plankthom avatar Jan 15 '22 21:01 plankthom

Note that current fastapi treats these sub-apps independently in their documentation (e.g. documented with the local paths):

http://localhost:8000/api/v1/docs image

(the current fastapi docs on that topic lag a bit behind)

plankthom avatar Jan 15 '22 22:01 plankthom

Hi. Original issue author here. Thank you @plankthom for looking into this.

My original use case was to make several small, reusable ASGI apps and combine them into larger app instances. Their URLs and URL namespace could differ depending on needs of the main app. There shouldn't be a need for any of the sub-apps to know where they reside, if ASGI framework can manage their URLs. Examples of such apps I had was authorization, and pub/sub messaging.

jussiarpalahti avatar Jan 17 '22 08:01 jussiarpalahti

@aminalaee did previous comments clarify the intent of this PR? If so would you consider reviewing it?

plankthom avatar Mar 31 '22 09:03 plankthom

@Kludex I want this too. This is smth I suffer too.

Here is my example: https://github.com/alex-oleshkevich/ohmyadmin/blob/master/ohmyadmin/resources.py#L485 App can have many Resource classes mounted under different paths. And it is impossible to generate a valid URL path to the route within the mounted resource by name. I had to wrap it in a hackish name=self.url_name('edit') to add a unique prefix.

See

class Resource(Router):
    def __init__():
        super().__init__(routes=[
			Route('/', self.index_view, name='index'),
			Route('/edit', self.edit_view, name='edit'),
       ])

app = Starlette(routes=[
	Mount('/app/products', Resource()),
	Mount('/app/users', Resource()),
	Mount('...', Resource()),
])

There is no easy way to generate a URL path for edit from index and vice versa:

def index_view(self, request):
	return self.request.url_path(???) # 'edit' is a global name

You have to prefix route names to make them unique:

Route('/', self.index_view, name='products_index'),
Route('/', self.index_view, name='users_index'),

And do this for all. This is error-prone and too verbose.

If you decide to namespace URL in Mount everything worsens. You cannot generate URL to /app/users without knowing the namespace name defined by the user (is it 'users' in this example).

Mount('/app/users', Resource(), name='users'),

# in view code of 3rd-party library
def index_view(self, request):
	return self.request.url_path(namespace???:routename???) # 'edit' is a global name

I think we can do something like this:

self.request.url_path('.edit')
self.request.url_path('.index')

If route name starts with dot, then we look up the current ASGI app first (inner router for example) and then iterate parents"

self.request.url_path('.edit') # works, returns `/api/products/edit`
self.request.url_path('.index') # works, returns `/api/products`

self.request.url_path('home') # works, returns `/`
self.request.url_path('index') # works, returns url for `index` route name, note it is not prefixed with dot.

The Flask does it in the same way with blueprints - https://flask.palletsprojects.com/en/2.2.x/blueprints/#building-urls

alex-oleshkevich avatar Oct 04 '22 09:10 alex-oleshkevich

@Kludex @alex-oleshkevich I can update this PR with the proposal (use . prefixed paths to have resolution local to the inner router) ... This will indeed improve backward compatibility, although IMO there is very little risk at incompatibility in the current proposal (other than that previously broken lookups would work now).

plankthom avatar Oct 05 '22 17:10 plankthom

Can someone reply this? Then we can proceed...

Kludex avatar Mar 16 '23 19:03 Kludex

Closing it as stale since no one replied my last question.

Happy to reopen. 👍

Kludex avatar May 31 '23 16:05 Kludex