Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a helper to generate the URL for a resource #1264

Open
kgriffs opened this issue May 12, 2018 · 11 comments
Open

Add a helper to generate the URL for a resource #1264

kgriffs opened this issue May 12, 2018 · 11 comments
Labels
enhancement needs contributor Comment on this issue if you'd like to volunteer to work on this. Thanks!
Milestone

Comments

@kgriffs
Copy link
Member

kgriffs commented May 12, 2018

As a REST-focused framework, Falcon really should make it easier to generate a URL for a given resource. This will help encourage the use of hypermedia by lowering the barrier to entry.

Prior Art

@kgriffs kgriffs added this to the Version 2.1 milestone May 12, 2018
@kgriffs kgriffs added needs contributor Comment on this issue if you'd like to volunteer to work on this. Thanks! and removed needs-decision proposal labels May 13, 2018
@kgriffs
Copy link
Member Author

kgriffs commented May 13, 2018

(Please comment below re a design proposal if you are interested in taking this on, thanks!)

@kgriffs kgriffs modified the milestones: Version 2.1, Version 2.0 May 13, 2018
@nZac
Copy link

nZac commented May 14, 2018

Much of the way that Flask.url_for is implemented in Werkzueg- specifically in the routing code.

The basic premise within that code is to utilize the Flask.url_map._rules_by_endpoint to grab the rule that is mapped to that endpoint and use that to build a URL with the appropriate values for the route. When reading through the compiled router I am not seeing anything obvious that indicates how we could use the same method. Does the compiled router keep a resource class -> url map anywhere?

I can understand that people do want it, but why do people want it? Is generating a URL based on resource a required competency of an API framework? Flask has Jinjna2 bundled for generating HTML so url_for seems to make sense there, within Falcon I haven't seen a need for it yet. Any details on the use case could be helpful.

A reason I could be confused is that, "inter-resource delegation"/"to facilitate one resource calling into another" and url_for seem to be different use cases. When I think of inter-resource delegation, I think of some resource calling an HTTP (get/post/put/delete) method of a different resource without going through the entire web stack. Is that what is meant? If so, how does that relate to url_for?

@timothyqiu
Copy link

Places I miss url_for:

  • For a 201 response, the url of another resource is needed to set the Location header.

    POST /gists
    Status: 201 Created
    Location: https://api.github.com/gists/aa5a315d61ae9438b18d
    
  • For a paged collection resource, the url of itself is needed to set the Link header or put them in the resulting response.

    GET /user/repos
    Status: 200 OK
    Link: <https://api.github.com/user/repos?page=3&per_page=100>; rel="next"
      <https://api.github.com/user/repos?page=50&per_page=100>; rel="last"
    

Without something like url_for, I have to hard code the route or parse & unparse req.uri.

@kgriffs kgriffs modified the milestones: Version 2.0, Version 2.2 Nov 8, 2018
@goodmami
Copy link

goodmami commented Jul 12, 2019

When I think of inter-resource delegation, I think of some resource calling an HTTP (get/post/put/delete) method of a different resource without going through the entire web stack.

I agree. Perhaps this issue could be renamed if it is about generating the URLs for resources?

Given how much weight Roy Fielding puts on link-driven APIs I'm surprised that a REST-focused framework like Falcon does not have a way to create a URL for a resource.

E.g, for some hypothetical API, if I query /products I would expect a list of product summaries including a link to each product's resource. I suppose Falcon expects users to format their own URLs (note: I'm new here; maybe there's something I'm missing), but it seems there's enough complexity that it's easy to get wrong. What if I have multiple routes to the same resource (e.g., /products and /products/{item})? What about escaping and encoding the URL so it is valid? Something like api.make_url(ProductResource, item_id) that returns /products/1234 or http://example.com/products/1234 would be very useful.

edit: here is essentially how I'm doing it now:

class ProductResource(object):
    def on_get(self, req, resp):
        urljoin = urllib.parse.urljoin
        quote = urllib.parse.quote
        data = [{'name': prod.name,
                 'url': urljoin(req.prefix, 'products/' + quote(prod.id))}
                for prod in self.products]
        resp.media = data
        resp.status = falcon.HTTP_OK

I'm not primarily a web-developer so I'm not sure how good that is. And what if req.prefix has a WSGI app part after the host? Maybe I should use '/products/' for this case?

@kgriffs
Copy link
Member Author

kgriffs commented Jun 10, 2021

Since #1228 speaks to delegating a response to another resource/route, I think it makes sense to rename this issue to focus solely on generating the hyperlink.

@kgriffs kgriffs changed the title feat: Inter-resource delegation Add a helper to generate the URL for a resource Jun 10, 2021
@kgriffs kgriffs mentioned this issue Aug 3, 2021
14 tasks
@vytas7 vytas7 modified the milestones: Version 3.1, Version 3.2 Mar 14, 2022
@vytas7 vytas7 mentioned this issue May 22, 2022
12 tasks
@jkmnt
Copy link

jkmnt commented Oct 30, 2024

I've noticed the url_for is on roadmap for 4.1.0.

I have a working implementation of url_for in falcon and quite like it. It's used for links and html-endpoints (they are actually good despite the the json-focused nature of the falcon).

If there is an interest, I could share some design points. The url_for is implemented by subclassing (ok, hacking) the stock CompiledRouter.

@CaselIT
Copy link
Member

CaselIT commented Oct 30, 2024

I think it would be useful, sure!

@jkmnt
Copy link

jkmnt commented Oct 30, 2024

Ok, here it is. Will help me too to document the feature )

Requirements:

  • No magic identifiers to invent (as flask's endpoint name). url_for accepts the very response handler and returns interpolated path string.
  • Path arguments to url_for are type-safe making it's impossible to miss some.
  • Urls are bound to the resource instance, not class since the same resource class may be instantiated at different urls.
  • There can't be different urls assigned to responder. Otherwise there is an ambiguity which one
    url_for should return. In case different urls are required, just add the reponder copy with suffix.
  • Avoid reimplementing the router, use the most of the stock CompiledRouter.

Implementation

  • Paths segments are represented as objects. This avoids the need to parse back template string when
    generating url_for
class ThingsEndpoint:
    def on_get(req, resp, *, thing_id: str, table_id: int): ...

things_ep = ThingsEndpoint()
router.add_route(Url("/api") / "things" / Arg("thing_id") / IntArg("table_id", max=100), things_ep)

__str__ method of Arg_xxx renders the placeholder in CompiledRouter-recognized format. So this IntArg will render {table_id:int(max=100)}

Url is the head of route and keeps the list of attached segments. The / op is overloaded ala pathlib.Path for aestetic. __str__ method of Url renders the whole template string, i.e. /api/things/{thing_id}/{table_id:int(max=100)}

Url have an interpolate method. Calling
this_url.interpolate(thing_id="a", table_id=2) returns /api/things/a/2. Thats's the url_for result !

  • The Url of reponder is stored in it's class instance.
    Getting back the url of responder is easy
def _set_route(responder: Callable, route: Url):
    cls_inst = responder.__self__
    unbound = responder.__func__
    cls_inst._routes[unbound] = route

def _get_route(responder: Callable):
    cls_inst = responder.__self__
    unbound = responder.__func__
    return cls_inst._routes[unbound]
  • The url_for just calls the interpolate of reponder's Url
def url_for(responder: Callable[Concatenate[Any, Any, P], None], *args: P.args, **kwargs: P.kwargs) -> str:
    return _get_route(responder).interpolate(*args, **kwargs)

This magic typing ensures the url_for arguments match the responder signature

url_for(things_ep.on_get, thing_id='a', table_id=2)
>>> '/api/things/a/2'
url_for(things_ep.on_get, thing_id='a') # Missing table id
  • The router is augumented like this
class MyRouter(CompiledRouter):
    # note the Url instead of the string
    def add_route(self, url: Url, resource: object, **kwargs:   Any) -> None:
        responders_map = map_http_methods(resource, kwargs.get("suffix"))
        # store the urls at responders class instance
        for r in responders_map.values():
            _set_route(r, url)
        # the url is stringified here for CompiledRouter
        return super().add_route(str(url), resource, **kwargs)

@CaselIT
Copy link
Member

CaselIT commented Oct 30, 2024

Interesting idea, thanks for sharing it!

As an api for falcon I think we shouldn't force use users to use path segments as the url like in your implementation.
I'm also not sure if requiring to use the instance is very convenient, but I don't see another option since the same class could in theory be mapped onto multiple url. I guess the same instance could also be re-used, but I see it as less likely.

Also the mapping instances - routes is likely better if stored in the app, so that we don't require or alter the instances of the responder classes provided by the users.

@jkmnt
Copy link

jkmnt commented Oct 30, 2024

Sure thing, the segments-as-objects would be a HUGE breaking change )
Just sharing the idea and looking forward to see url_for landing in some 4.x release.

@jkmnt
Copy link

jkmnt commented Oct 30, 2024

... regarding passing around the resource instances, there are ways to make it more convenient.
I use this simple (simplified here) pattern and find it ok:

class BaseEp:
    def __init__(self, app: MyAppWithEpRegistryAndServices)
       self.app = app

class ThingEp(BaseEp):
     ...

class OtherThingEp(BaseEp):
    def on_get(...):
        thing_id = self.app.db.fetch(...)
        url = url_for(self.app.thing_ep.on_post, thing_id=thing_id)

class MyAppWithEpRegistryAndServices:
    def __init__(self):
        self.db = SqliteDb()

        self.thing_ep = ThingEp(self)
        self.other_thing_ep = OtherThingEp(self)

        self.falcon = falcon.App(...)
        self.falcon.add_route(Url(...), self.thing_ep)
        self.falcon.add_route(Url(...), self.other_thing_ep)

app = MyAppWithEpRegistryAndServices()
make_server(app.falcon).serve_forever()

The best thing is VSCode autocompletion here. Typing self.app. lists all app's endpoints to choose.
The services are easy accessible too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement needs contributor Comment on this issue if you'd like to volunteer to work on this. Thanks!
Projects
None yet
Development

No branches or pull requests

7 participants