40

I'm trying to implement a redirecting pattern, similar to what StackOverflow does:

@route('/<int:id>/<username>/')
@route('/<int:id>/')
def profile(id, username=None):
    user = User.query.get_or_404(id)

    if user.clean_username != username:
        return redirect(url_for('profile', id=id, username=user.clean_username))

    return render_template('user/profile.html', user=user) 

Here's a simple table of what should happen:

URL                         Redirects/points to
====================================================
/user/123                   /user/123/clean_username
/user/123/                  /user/123/clean_username
/user/123/foo               /user/123/clean_username
/user/123/clean_username    /user/123/clean_username
/user/123/clean_username/   /user/123/clean_username/
/user/125698                404

Right now, I can access the profile with /user/1/foo, but /user/1 produces a BuildError. I've tried the alias=True keyword argument and something with defaults, but I'm not quite sure what isn't working.

How would I have one route redirect to the other like this?

Blender
  • 289,723
  • 53
  • 439
  • 496
  • I don't know if I am missing something.. but shouldn't the function be inside of a class and have a `self` parameter? (assuming you are using flask classy) Other thing that may be wrong if you are using blueprints is that you should add the blueprint name to url_for (http://flask.pocoo.org/docs/blueprints/#building-urls). – Gabriel Jordão Jul 12 '13 at 17:21
  • If you're getting a `BuildError` then there's something wrong with your call to `url_for`. Can you provide the traceback? – Matt W Jul 12 '13 at 18:23
  • @GabrielJordão: It's a simplified example that illustrates the problem. Using blueprints or Flask-Classy wouldn't really change anything. – Blender Jul 12 '13 at 19:31
  • can you post your `app.url_map`? this should help figure out what's making `url_for` choke. – dnozay Jul 14 '13 at 17:41
  • So, uh… which one should get the bounty? – Ry- Jul 18 '13 at 15:37
  • @minitech: Well, it turned out that my problem was a stupid mistake on my part, so I'm not sure. – Blender Jul 19 '13 at 08:34
  • If we're being literal, I do believe `strict_slashes` makes a difference here, `/user/123/foo` redirects to `/user/123/foo/`. it's the `/user/123/foo/` url, which doesn't appear in your table, that will execute your function and is supposed to redirect to the correct url. – dnozay Jul 19 '13 at 15:51

4 Answers4

44

debugging routes:

Update: to address the primary question "what's wrong with my routes", the simplest way to debug that is to use app.url_map; e.g:

>>> app.url_map
Map([<Rule '/user/<id>/<username>/' (HEAD, OPTIONS, GET) -> profile>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>,
 <Rule '/user/<id>/' (HEAD, OPTIONS, GET) -> profile>])

In this case, this confirms that the endpoint is correctly set. Here is an example showcasing both plain flask and flask-classy:

from app import app, models
from flask import g, redirect, url_for, render_template, request
from flask.ext.classy import FlaskView, route

@app.route('/user/<int:id>', strict_slashes=False)
@app.route('/user/<int:id>/<username>', strict_slashes=False)
def profile(id, username=None):
    user = models.User.query.get_or_404(id)
    if user.clean_username != username:
        return redirect(url_for('profile', id=id, username=user.clean_username))
    return render_template('profile.html', user=user)

class ClassyUsersView(FlaskView):
    @route('/<int:id>', strict_slashes=False)
    @route('/<int:id>/<username>', strict_slashes=False, endpoint='classy_profile')
    def profile(self, id, username=None):
        user = models.User.query.get_or_404(id)
        if user.clean_username != username:
            return redirect(url_for('classy_profile', id=id, username=user.clean_username))
        return render_template('profile.html', user=user)

ClassyUsersView.register(app)

They have different endpoints, which you need to take into account for url_for:

>>> app.url_map
Map([<Rule '/classyusers/<id>/<username>' (HEAD, OPTIONS, GET) -> classy_profile>,
 <Rule '/user/<id>/<username>' (HEAD, OPTIONS, GET) -> profile>,
 <Rule '/classyusers/<id>' (HEAD, OPTIONS, GET) -> ClassyUsersView:profile_1>,
 <Rule '/static/<filename>' (HEAD, OPTIONS, GET) -> static>,
 <Rule '/user/<id>' (HEAD, OPTIONS, GET) -> profile>])

Without flask-classy the name of the endpoint is the function name, but as you've found out, this is different for when using classy, and you can either look at the endpoint name with url_map() or assign it in your route with @route(..., endpoint='name').


less redirects:

To respond to the urls you posted while minimizing the amount of redirects, you need to use strict_slashes=False, this will make sure to handle requests that are not terminated with a / instead of redirecting them with a 301 redirect to their /-terminated counterpart:

@app.route('/user/<int:id>', strict_slashes=False)
@app.route('/user/<int:id>/<username>', strict_slashes=False)
def profile(id, username=None):
    user = models.User.query.get_or_404(id)
    if user.clean_username != username:
        return redirect(url_for('profile', id=id, username=user.clean_username))
    return render_template('profile.html', user=user)

here is the result:

>>> client = app.test_client()
>>> def check(url):
...     r = client.get(url)
...     return r.status, r.headers.get('location')
... 
>>> check('/user/123')
('302 FOUND', 'http://localhost/user/123/johndoe')
>>> check('/user/123/')
('302 FOUND', 'http://localhost/user/123/johndoe')
>>> check('/user/123/foo')
('302 FOUND', 'http://localhost/user/123/johndoe')
>>> check('/user/123/johndoe')
('200 OK', None)
>>> check('/user/123/johndoe/')
('200 OK', None)
>>> check('/user/125698')
('404 NOT FOUND', None)

Behavior of strict_slashes:

with strict_slashes=False

URL                         Redirects/points to              # of redirects
===========================================================================
/user/123                   302 /user/123/clean_username          1
/user/123/                  302 /user/123/clean_username          1
/user/123/foo               302 /user/123/clean_username          1
/user/123/foo/              302 /user/123/clean_username          1
/user/123/clean_username    302 /user/123/clean_username          1
/user/123/clean_username/   200 /user/123/clean_username/         0
/user/125698                404

with strict_slashes=True (the default)
any non '/'-terminated urls redirect to their '/'-terminated counterpart

URL                         Redirects/points to              # of redirects
===========================================================================
/user/123                   301 /user/123/                        2
/user/123/foo               301 /user/123/foo/                    2
/user/123/clean_username    301 /user/123/clean_username/         1
/user/123/                  302 /user/123/clean_username/         1
/user/123/foo/              302 /user/123/clean_username/         1
/user/123/clean_username/   200 /user/123/clean_username/         0
/user/125698                404

example:
"/user/123/foo" not terminated with '/' -> redirects to "/user/123/foo/"
"/user/123/foo/" -> redirects to "/user/123/clean_username/"

I believe it does exactly what your test matrix is about :)

dnozay
  • 23,846
  • 6
  • 82
  • 104
32

You've almost got it. defaults is what you want. Here is how it works:

@route('/<int:id>/<username>/')
@route('/<int:id>/', defaults={'username': None})
def profile(id, username):
    user = User.query.get_or_404(id)

    if username is None or user.clean_username != username:
        return redirect(url_for('profile', id=id, username=user.clean_username))

    return render_template('user/profile.html', user=user)

defaults is a dict with default values for all route parameters that are not in the rule. Here, in the second route decorator there is no username parameter in the rule, so you have to set it in defaults.

dAnjou
  • 3,823
  • 7
  • 27
  • 34
  • 3
    FYI for anyone else, if your defaults will be the same for all routes for the same function, you can set the defaults in the parameters to the function definition instead of the defaults dictionary of each route decorator. This way you only specify them in one place. – RobertoCuba Jul 03 '16 at 21:45
  • Can't we simply provide the default values directly to the function? E.g., ```def profile(id, username=None):``` – Aditya Shaw Jan 29 '21 at 10:55
  • @AdityaShaw Maybe, my answer is over 7 years old and it's been a few years since I've used Flask. It's possible that the API has changed in the meantime. – dAnjou Feb 01 '21 at 15:11
3

Well, it looks like my original code actually worked. Flask-Classy was the issue here (and since this question has a bounty, I can't delete it).

I forgot that Flask-Classy renames routes, so instead of url_for('ClassName:profile'), I'd have to select the outermost decorator's route:

url_for('ClassName:profile_1')

An alternative would be to explicitly specify an endpoint to the route:

@route('/<int:id>/<username>/', endpoint='ClassName:profile')
Blender
  • 289,723
  • 53
  • 439
  • 496
2

I don't understand why you are redirecting. You don't gain anything with the redirect and as you mentioned yourself, you end up just querying the database multiple times. You don't use the given username in any meaningful way, so just ignore it.

@route('/<int:id>/<username>/')
@route('/<int:id>/')
def profile(id, username=None):
    user = User.query.get_or_404(id)
    return render_template('user/profile.html', user=user)

This will satisfy all of your given examples.

Michael Davis
  • 2,350
  • 2
  • 21
  • 29
  • Reddit does it this way, but I like StackOverflow's approach a little more. Either way, this is just my example. I'm looking more for a solution to the route redirection problem than for my specific use case. – Blender Jun 25 '13 at 00:09