Skip to content

fix(api): strip trailing slash on namespace path to avoid '//' rules (#74)#652

Open
jbbqqf wants to merge 1 commit into
python-restx:masterfrom
jbbqqf:fix/74-double-slash-namespace
Open

fix(api): strip trailing slash on namespace path to avoid '//' rules (#74)#652
jbbqqf wants to merge 1 commit into
python-restx:masterfrom
jbbqqf:fix/74-double-slash-namespace

Conversation

@jbbqqf
Copy link
Copy Markdown

@jbbqqf jbbqqf commented May 22, 2026

Summary

Api.add_namespace(ns, path=path) (and Api.ns_urls) builds rule
paths by naive concatenation:

def ns_urls(self, ns, urls):
    path = self.get_ns_path(ns) or ns.path
    return [path + url for url in urls]

That is fine for path='/api' + /foo = /api/foo, but the moment the
caller does what the docs lead them to — mount a namespace at the API
root with path='/' — the rule ends up as '//foo'. path='/api/'
produces '/api//foo'. Both shapes leak into app.url_map and break
url_for/Swagger reverse routing.

Fix: normalise the prefix with rstrip('/') before concatenating, so
'/' + '/foo' becomes '' + '/foo' = '/foo' and '/api/' + '/foo'
becomes '/api' + '/foo' = '/api/foo'. This matches the normalisation
already done on Namespace.path for the namespace's own _path
(flask_restx/namespace.py:67).

Fixes #74.

Context

The OP (gyre) raised this in 2020 wanting to do exactly what the
flask-restx docs recommend for a root-mounted namespace:

base_api = Namespace("base")
api.add_namespace(base_api, path="/")

flask_restx/namespace.py:65-67 already does
(self._path or ("/" + self.name)).rstrip("/") precisely because of
this concern. The bug is that ns_urls bypasses that normalisation
when the caller specifies a path explicitly via add_namespace.

Changes

  • flask_restx/api.py:486-491: add path = path.rstrip("/") in
    ns_urls, with a comment explaining why.
  • tests/test_api.py: regression test test_ns_path_no_double_slash
    exercising both path="/" and path="/api/". It asserts there is no
    '//' substring in any URL rule and that url_for returns the
    expected canonical form.
  • CHANGELOG.rst: 1.3.3 bug-fix entry.

Reproduce BEFORE/AFTER yourself (copy-paste)

# --- one-time setup ---
git clone https://github.com/python-restx/flask-restx.git /tmp/restx-74 && cd /tmp/restx-74
python3 -m venv .venv && . .venv/bin/activate
pip install -q -e ".[test]" tzdata

# --- BEFORE: origin/master, expect '//foo' and '/api//foo' ---
git checkout origin/master
python -c "
from flask import Flask
from flask_restx import Api, Namespace, Resource
for prefix in ('/', '/api/'):
    app = Flask(__name__)
    api = Api(app)
    ns = Namespace('ns')
    @ns.route('/foo')
    class Foo(Resource):
        def get(self): return {}
    api.add_namespace(ns, path=prefix)
    rules = sorted(str(r) for r in app.url_map.iter_rules() if 'foo' in str(r))
    print(f'path={prefix!r:8s} rules={rules}')
"
# Expected (BEFORE the fix):
#   path='/'      rules=['//foo']
#   path='/api/'  rules=['/api//foo']

# --- AFTER: this branch, expect '/foo' and '/api/foo' ---
git fetch https://github.com/jbbqqf/flask-restx.git fix/74-double-slash-namespace
git checkout FETCH_HEAD
python -c "
from flask import Flask
from flask_restx import Api, Namespace, Resource
for prefix in ('/', '/api/'):
    app = Flask(__name__)
    api = Api(app)
    ns = Namespace('ns')
    @ns.route('/foo')
    class Foo(Resource):
        def get(self): return {}
    api.add_namespace(ns, path=prefix)
    rules = sorted(str(r) for r in app.url_map.iter_rules() if 'foo' in str(r))
    print(f'path={prefix!r:8s} rules={rules}')
"
# Expected (AFTER the fix):
#   path='/'      rules=['/foo']
#   path='/api/'  rules=['/api/foo']

What I ran locally

$ pytest tests/test_api.py -k "ns_path" -v 2>&1 | tail -5
collected 29 items / 27 deselected / 2 selected
tests/test_api.py::APITest::test_ns_path_prefixes PASSED
tests/test_api.py::APITest::test_ns_path_no_double_slash PASSED

$ pytest -q --no-header 2>&1 | tail -2
FAILED tests/test_inputs.py::URLTest::test_check - Failed: DID NOT RAISE <class 'ValueError'>
1 failed, 1237 passed in 8.76s

The single failure (tests/test_inputs.py::URLTest::test_check) is
pre-existing on master and unrelated to this change — it depends on
http://this-domain-should-not-exist.com actually not resolving, which
is no longer true on the network where the suite ran.

Edge cases

# add_namespace path ns.route url Resulting rule Verified by
1 "/" "/foo" "/foo" (was "//foo") new regression test
2 "/api/" (trailing /) "/foo" "/api/foo" (was "/api//foo") new regression test
3 "/api_test" (no trailing /) "/test/" "/api_test/test/" (unchanged) pre-existing test_ns_path_prefixes
4 omitted (no add_namespace path) resource url unchanged — ns.path already rstrip('/')-ed pre-existing tests, no change in this path

Risk / blast radius

The only behavioural change is that prefix paths ending in / lose
their trailing / before being concatenated with the resource URL.
This is purely a normalisation: before this PR those paths produced
syntactically broken '//' rules that Flask still accepts but that
break url_for round-tripping and look wrong in Swagger. There is no
caller that was deliberately relying on '//foo' as a valid path.

The existing test_ns_path_prefixes (which uses "/api_test" with no
trailing slash) still passes unchanged.


PR drafted with assistance from Claude Code (Anthropic). The change was
reviewed manually against flask-restx's source. The reproducer block
above is the one I used during development; reviewers can paste it
verbatim.

…ython-restx#74)

When add_namespace(ns, path=path) is called with path='/' (the documented
way to mount a namespace at the root) or with any path ending in '/',
ns_urls did a naive 'path + url' concatenation, producing rules like
'//foo' or '/api//foo'. Normalise the join by stripping the trailing
slash on the prefix.

Add a regression test that asserts no rule in app.url_map ends up with
'//' for path='/' and path='/api/'.

Fixes python-restx#74.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

wrong url strings generated in rules when adding namespace with path "/"?

1 participant