Route definition with extension, that is text after v path var declaration.fails in 21 but works in 20

I have a route like this,
which works fine in sanic 20.12

@bp.route('/tiff/<id>.tiff', name='img_tiff')

but fails on 21 with

  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic/app.py", line 421, in blueprint
    blueprint.register(self, options)
  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic/blueprints.py", line 227, in register
    route = app._apply_route(apply_route)
  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic/app.py", line 335, in _apply_route
    routes = self.router.add(**params)
  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic/router.py", line 128, in add
    route = super().add(**params)  # type: ignore
  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic_routing/router.py", line 133, in add
    path = parts_to_path(
  File "/usr/local/miniconda3/envs/web39/lib/python3.9/site-packages/sanic_routing/utils.py", line 80, in parts_to_path
    raise ValueError(f"Invalid declaration: {part}")
ValueError: Invalid declaration: <id>.tiff

removing the extension avoids the error:

 @bp.route('/tiff/<id>', name='img_tiff')

After some more checks it seems in 20.12 it doesn’t worked as intended, as the text after the variable was silently ignored, and my code used an id with was the real filename, so two errors canceling each other…

My Question:
Is it possible to create a route like my original with sanic 21?
Is this behaviour intentional, as only a complete path segment (text between two “/)” can be an argument for a route or should it work more like a pattern or regex like i used it?

The Router changed in 21, from regex to objects, which could change a lot here.

You can, a lot changed in the router. We were ALMOST able to achieve a 1:1 parity with the old router. You got caught in one of the edge cases.

To do this in v21:

@app.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>")
def handler(request, tiff_id):
    return text(f"{tiff_id=}")
$ curl localhost:9999/tiff/1234/tiff
tiff_id='1234'

In the new router, you need the entire segment to be a parameter.

regex parameter… Cheater… :wink:

It worked,
… but only on the main app, not in a blueprint.
if using

 bp = Blueprint("foo", url_prefix="/foo")
@bp.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>")

in a python modul, i get a 404 error from sanic, actually for everey regex i tried…

:laughing:

:eyes: Looking into it.

from sanic import Blueprint, Sanic, text

app = Sanic("pathtest")
bp1 = Blueprint("bp")
bp2 = Blueprint("bp2", url_prefix="/bp2")


@app.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>")
def handler1(request, tiff_id):
    return text(f"{tiff_id=}")


@bp1.get(r"/bp/<tiff_id:(?P<tiff_id>.*)\.tiff>")
def handler2(request, tiff_id):
    return text(f"{tiff_id=}")


@bp2.get(r"/<tiff_id:(?P<tiff_id>.*)\.tiff>")
def handler3(request, tiff_id):
    return text(f"{tiff_id=}")


app.blueprint(bp1)
app.blueprint(bp2)
app.run(port=9999, debug=True)

This works fine.

Can you send me an example of what is not working for you?

What version of sanic and sanic-routing are you using?

Conda env where sanic is installed using pip (see other thread… :wink:

# Name                    Version                   Build  Channel
pytest-sanic              1.7.0                    pypi_0    pypi
sanic                     21.3.2                   pypi_0    pypi
sanic plugin toolkit      1.0.0                    pypi_0    pypi
sanic-cors                1.0.0                    pypi_0    pypi
sanic-plugins-framework   0.9.4.post1              pypi_0    pypi
sanic-routing             0.5.0                    pypi_0    pypi
sanic-testing             0.3.0                    pypi_0    pypi

Found … something.

using my setup, this works:

@fax_bp.get(r"/<tiff_id:(?P<tiff_id>.*)\.tiff>")

url is then like /fax/1234.tiff

This doesn’t:

@fax_bp.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>")

url sould be like /fax/tiff/1234.tiff, but gives 404.

Semme like the extra path at the beginning breaks it…

Update sanic routing. I think current version is 0.5.2

same behaviour with 0.5.2 :frowning:

Can you give me all the route definitions on that blueprint?

lazy grep results:

% rg @fax_bp. faxes/fax.py -A2
68:@fax_bp.listener('before_server_start')
69-async def init_app_stuff(app, loop):
--
208:@fax_bp.route('/list/', name='fax_list', methods=['GET', 'OPTIONS'])
209-async def get_fax_list(request):
--
261:@fax_bp.route('/locked', name='fax_locked')
262-async def get_fax_locked(request):
--
267:@fax_bp.route('/lock-list', name='fax_locked_json')
268-async def get_fax_locked(request):
--
274:@fax_bp.route('/<fax_id>/meta', name='fax_meta')
275-@authorized()
276-async def get_fax_meta(request, fax_id):
--
320:@fax_bp.route('/random', name='fax_next_random')
321-@authorized()
322-async def get_next_fax(request):
--
412:@fax_bp.post('/next', name='fax_next', strict_slashes=False)
413-@authorized()
414-async def get_next_fax(request):
--
464:@fax_bp.route('/<fax_id>/lock', name='fax_lock')
465-@authorized()
466-async def get_fax_lock(request, fax_id):
--
499:@fax_bp.route('/<fax_id>/unlock/<token>', name='fax_unlock')
500-@authorized()
501-async def get_fax_unlock(request, fax_id, token):
--
528:@fax_bp.route('/<fax_id>', name='fax_img')
529-@authorized()
530-async def get_fax_img(request, fax_id):
--
543:@fax_bp.route('/', name='fax_submit', methods=["POST"], strict_slashes=False)
544-@authorized()
545-async def post_fax(request):
--
624:@fax_bp.get('/tiff/<fax_id>', name='fax_img_tiff')
625:# does not work: @fax_bp.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>", name='fax_img_tiff')
626:# also not working: @fax_bp.get(r"/<tiff_id:(?P<tiff_id>.*)\.tiff>", name='fax_img_tiff')
627-@authorized()
628-async def get_fax_tiff_img(request, fax_id):
--
641:@fax_bp.route('/unlock/all', name='fax_unlock_all')
642-async def get_fax_unlock_all(request):
--
652:@fax_bp.route('/values/<column>', name='faxlist_lookup')
653-@authorized()
654-async def get_column_values(request, column):

authorized does just as it says:

def authorized():
    def decorator(f):
        @wraps(f)
        async def decorated_function(request, *args, **kwargs):
            is_authorized = check_request_for_authorization_status(request)
            if is_authorized:
                resp = await f(request, *args, **kwargs)
                return resp
            else:
                return json({'status': 'not_authorized'}, 403)
        return decorated_function
    return decorator

stolen from SO…

Hmm… I am working on a new implementation that will handle this use case better. But, in the mean time, I got it working for you.

fax_bp = Blueprint("fax_bp")


@fax_bp.route("/list/", name="fax_list", methods=["GET", "OPTIONS"])
async def get_fax_list(request):
    return text("get_fax_list")


@fax_bp.route("/locked", name="fax_locked")
async def get_fax_locked(request):
    return text("get_fax_locked")


@fax_bp.route("/lock-list", name="fax_locked_json")
async def get_fax_locked(request):
    return text("get_fax_locked")


@fax_bp.route("/<fax_id>/meta", name="fax_meta")
async def get_fax_meta(request, fax_id):
    return text("get_fax_meta")


@fax_bp.route("/random", name="fax_next_random")
async def get_next_fax(request):
    return text("get_next_fax")


@fax_bp.post("/next", name="fax_next", strict_slashes=False)
async def get_next_fax(request):
    return text("get_next_fax")


@fax_bp.route("/<fax_id>/lock", name="fax_lock")
async def get_fax_lock(request, fax_id):
    return text("get_fax_lock")


@fax_bp.route("/<fax_id>/unlock/<token>", name="fax_unlock")
async def get_fax_unlock(request, fax_id, token):
    return text("get_fax_unlock")


@fax_bp.route("/<fax_id:.*>", name="fax_img")
async def get_fax_img(request, fax_id):
    return text("get_fax_img")


@fax_bp.route("/", name="fax_submit", methods=["POST"], strict_slashes=False)
async def post_fax(request):
    return text("post_fax")


@fax_bp.get(r"/tiff/<fax_id:.*(?<!\.tiff)>", name="fax_img_tiff")
async def get_fax_tiff_img(request, fax_id):
    return text("get_fax_tiff_img")


@fax_bp.route("/unlock/all", name="fax_unlock_all")
async def get_fax_unlock_all(request):
    return text("get_fax_unlock_all")


@fax_bp.route("/values/<column>", name="faxlist_lookup")
async def get_column_values(request, column):
    return text("get_column_values")


@fax_bp.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>", name="fax_img_tiff_ext")
async def fax_img_tiff(request, tiff_id):
    return text("fax_img_tiff")

The applicable changes are these three:

@fax_bp.route("/<fax_id:.*>", name="fax_img")
async def get_fax_img(request, fax_id):
    return text("get_fax_img")

@fax_bp.get(r"/tiff/<fax_id:.*(?<!\.tiff)>", name="fax_img_tiff")
async def get_fax_tiff_img(request, fax_id):
    return text("get_fax_tiff_img")

@fax_bp.get(r"/tiff/<tiff_id:(?P<tiff_id>.*)\.tiff>", name="fax_img_tiff_ext")
async def fax_img_tiff(request, tiff_id):
    return text("fax_img_tiff")
$ curl localhost:9999/tiff/123 localhost:9999/tiff/123.tiff localhost:9999/123
get_fax_tiff_img
fax_img_tiff
get_fax_img

does

curl localhost:9999/fax/123/lock

(or /meta) work for you…?
i think the

@fax_bp.route("/<fax_id:.*>", name="fax_img")

swallows this…

But i need to investigate further.

Hi @ar4ch
Just an aside, you can remove sanic-plugins-framework 0.9.4.post1 from your conda env. It is leftover from an older sanic install. Plugins like Sanic-CORS use Sanic-Plugin-Toolkit v1.0 now so Sanic-Plugin-Framework is not needed.

1 Like

done. Thanks for the heads up.

Ignore this. Can’t reproduce…

Your solution works, but seems to be some kind of dark magic…

# breaks the tiff route @fax_bp.route("/<fax_id>", name="fax_img")
# only this this works:
@fax_bp.route("/<fax_id:.*>", name="fax_img")
async def get_fax_img(request, fax_id):
    return text("get_fax_img")

@fax_bp.get(r"/tiff/<fax_id:(?P<fax_id>.*)\.tiff>", name="fax_img_tiff_ext")
async def fax_img_tiff(request, tiff_id):
    return text("fax_img_tiff")

Why…?
The regex does trigger some internal … stuff?

I’ll work with those for now. :relieved::

Thanks for all the help

All regex is black magic