From 1bedfa177ef1a4b37f9e62f68b519fa2995fafa8 Mon Sep 17 00:00:00 2001 From: Pascal Repond Date: Mon, 7 Sep 2026 08:44:57 +0200 Subject: [PATCH] feat: serve one value of the variable prefix without naming it An application whose prefix carries a code of its own -- a tenant, an organisation -- usually has a value that stands above the others: the section everyone shares, the one a reader lands on. Spelling it out in every URL says nothing, and the wiki had no way of leaving it out. `WIKI_URL_PREFIX_DEFAULTS` names the value each variable part of the prefix takes when a reader omits it. Every rule of the blueprint is then registered a second time under the prefix stripped of the variables the key names, holding their values as Werkzeug defaults, which is all it takes for the short URL to be served, to be the one the wiki builds for those values, and to receive a redirect from the spelled-out one. Any other value keeps its part of the prefix. The variables the key leaves out keep theirs as well: a rule needs a value for them either way, and Werkzeug redirects between two rules only when they carry the same arguments. A value naming no variable of the prefix, a misspelt key, is dropped rather than handed to the views, which would take no such argument. Co-Authored-by: Pascal Repond --- README.md | 14 ++++++ flask_wiki/__init__.py | 47 +++++++++++++++--- flask_wiki/config.py | 2 + tests/test_url_prefix.py | 100 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ed5bc74..0955aca 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,7 @@ The `VIEW` permissions are enforced server-side via route decorators. The `UI` p |-----|---------|-------------| | `WIKI_HOME` | `'home'` | Default page for `/` | | `WIKI_URL_PREFIX` | `'/help'` | URL prefix for the wiki blueprint | +| `WIKI_URL_PREFIX_DEFAULTS` | `{}` | Value each variable part of the prefix takes when left out | | `WIKI_CONTENT_DIR` | `'./data'` | Directory for Markdown files | | `WIKI_UPLOAD_FOLDER` | `'./data/files'` | Directory for uploaded images | | `WIKI_ALLOWED_EXTENSIONS` | `{'png','jpg','jpeg','gif','svg'}` | Allowed upload types | @@ -141,6 +142,19 @@ name does not collide with an argument of the wiki views -- `url` and Building such a URL from outside the wiki, from a footer for instance, means passing the value: `url_for('wiki.index', org_code='unifr')`. +One value may be left out of the URL altogether. Name it in +`WIKI_URL_PREFIX_DEFAULTS` and the wiki serves its whole navigation under the +prefix stripped of it as well -- any other variable part keeps its place: + +```python +app.config["WIKI_URL_PREFIX"] = "//help" +app.config["WIKI_URL_PREFIX_DEFAULTS"] = {"org_code": "global"} +``` + +`/help/setup` is then served, `url_for('wiki.index', org_code='global')` builds +`/help/`, and `/global/help/setup` redirects to `/help/setup`. Any other value +keeps its part of the prefix, `/unifr/help/setup`. + The uploaded files hang from the static part of the prefix, `/help/files/` in the example above, whatever the prefix a reader came through. A WSGI mount point carries no variable, and neither does the URL of an image written in a diff --git a/flask_wiki/__init__.py b/flask_wiki/__init__.py index ad8301d..7c1ca2d 100644 --- a/flask_wiki/__init__.py +++ b/flask_wiki/__init__.py @@ -32,23 +32,58 @@ def init_app(self, app): self.init_config(app) prefix = app.config["WIKI_URL_PREFIX"] self.prefix_variables = VARIABLE_PART.findall(prefix) - # the uploaded files hang from the static part of the prefix: a WSGI - # mount point carries no variable, and neither does the URL of an image - # written in a page - files_prefix = VARIABLE_PART.sub("", prefix) + # the prefix stripped of its variable parts: the uploaded files hang from + # it, as a WSGI mount point carries no variable, and neither does the URL + # of an image written in a page + static_prefix = VARIABLE_PART.sub("", prefix) app.register_blueprint(blueprint, url_prefix=prefix) + self.init_prefix_defaults(app) app.add_url_rule( - f"{files_prefix}/files/", + f"{static_prefix}/files/", "uploaded_files", build_only=True, ) app.wsgi_app = SharedDataMiddleware( app.wsgi_app, - {f"{files_prefix}/files": app.config["WIKI_UPLOAD_FOLDER"]}, + {f"{static_prefix}/files": app.config["WIKI_UPLOAD_FOLDER"]}, ) app.extensions["flask-wiki"] = self + def init_prefix_defaults(self, app): + """Serve the default value of a prefix variable without it in the URL. + + ``WIKI_URL_PREFIX_DEFAULTS`` gives the value each variable of the prefix + takes when a reader leaves it out. Registering every rule a second time + under the prefix stripped of these variables, holding their values as + Werkzeug defaults, is all it takes for the short URL to be served, to be + the one the wiki builds for those values, and to receive a redirect from + the spelled-out one. A variable the key does not name keeps its part of the + prefix, as a rule still needs a value for it, and a value naming no + variable of the prefix is dropped, as no rule would take it. + + :param app: Flask application instance + """ + prefix = app.config["WIKI_URL_PREFIX"] + defaults = { + name: value + for name, value in app.config["WIKI_URL_PREFIX_DEFAULTS"].items() + if name in self.prefix_variables + } + if not defaults: + return + stripped = VARIABLE_PART.sub(lambda part: "" if part[1] in defaults else part[0], prefix) + for rule in list(app.url_map.iter_rules()): + if rule.endpoint.startswith(f"{blueprint.name}."): + app.add_url_rule( + rule.rule.replace(prefix, stripped, 1), + endpoint=rule.endpoint, + view_func=app.view_functions[rule.endpoint], + defaults=defaults, + # Flask puts HEAD and OPTIONS back, as it did for the copied rule + methods=rule.methods - {"HEAD", "OPTIONS"}, + ) + def init_config(self, app): """Initialize configuration.""" for k in dir(config): diff --git a/flask_wiki/config.py b/flask_wiki/config.py index 07aedd3..01f20e6 100644 --- a/flask_wiki/config.py +++ b/flask_wiki/config.py @@ -28,6 +28,8 @@ # None means every language of WIKI_LANGUAGES, in order; [] disables the cascade WIKI_FALLBACK_LANGUAGES = None WIKI_URL_PREFIX = "/help" +# The value each variable part of the prefix takes when a reader leaves it out +WIKI_URL_PREFIX_DEFAULTS = {} WIKI_CONTENT_DIR = "./data" WIKI_UPLOAD_FOLDER = os.path.join(WIKI_CONTENT_DIR, "files") WIKI_INDEX_DIR = "./index" diff --git a/tests/test_url_prefix.py b/tests/test_url_prefix.py index 9f94cb7..3f2f16b 100644 --- a/tests/test_url_prefix.py +++ b/tests/test_url_prefix.py @@ -4,6 +4,7 @@ """Tests for a WIKI_URL_PREFIX carrying a variable part.""" from pathlib import Path +from urllib.parse import urlsplit import pytest from flask import url_for @@ -84,3 +85,102 @@ def test_each_host_keeps_its_own_prefix(prefixed_client): for html in (first, second): assert "first.example.org" not in html assert "second.example.org" not in html + + +@pytest.fixture(scope="module") +def default_value_app(tmp_path_factory): + """Create an application serving one value of the prefix without naming it.""" + return make_app( + tmp_path_factory, + WIKI_URL_PREFIX="//help", + WIKI_URL_PREFIX_DEFAULTS={"organisation": "global"}, + SERVER_NAME=None, + ) + + +@pytest.fixture(scope="module") +def default_value_client(default_value_app): + """Create a test client for the application with a default prefix value.""" + return default_value_app.test_client() + + +def test_the_default_value_is_served_without_the_variable_part(default_value_client): + """Test that the wiki answers under the prefix stripped of its variable part.""" + assert default_value_client.get("/help/home/").status_code == 200 + + +def test_spelling_out_the_default_value_redirects_to_the_short_url(default_value_client): + """Test that the URL naming the default value redirects to the one omitting it.""" + response = default_value_client.get("/global/help/home/") + + assert response.status_code == 308 + assert urlsplit(response.location).path == "/help/home/" + + +def test_urls_omit_the_default_value(default_value_client): + """Test that the wiki leaves the default value out of every URL it builds.""" + html = default_value_client.get("/help/home/").data.decode() + + assert 'href="/help/"' in html + assert 'action="/help/search"' in html + assert 'href="/help/sample/"' in html + assert "/global/help/" not in html + + +def test_another_value_keeps_its_variable_part(default_value_client): + """Test that a value other than the default one stays in the URLs.""" + html = default_value_client.get("/hepvs/help/home/").data.decode() + + assert 'href="/hepvs/help/"' in html + assert 'href="/hepvs/help/sample/"' in html + + +def test_urls_built_from_outside_honour_the_default_value(default_value_app): + """Test that naming the default value from outside the wiki builds the short URL.""" + with default_value_app.test_request_context(): + assert url_for("wiki.index", organisation="global") == "/help/" + assert url_for("wiki.index", organisation="hepvs") == "/hepvs/help/" + + +@pytest.fixture(scope="module") +def partial_default_client(tmp_path_factory): + """Create a test client for a prefix whose second variable has no default.""" + app = make_app( + tmp_path_factory, + WIKI_URL_PREFIX="//
/help", + WIKI_URL_PREFIX_DEFAULTS={"organisation": "global"}, + SERVER_NAME=None, + ) + return app.test_client() + + +def test_a_variable_without_a_default_keeps_its_part_of_the_prefix(partial_default_client): + """Test that only the variables named in the defaults leave the URL.""" + assert partial_default_client.get("/staff/help/home/").status_code == 200 + assert partial_default_client.get("/help/home/").status_code == 404 + + html = partial_default_client.get("/staff/help/home/").data.decode() + + assert 'href="/staff/help/"' in html + assert 'href="/staff/help/sample/"' in html + + +def test_spelling_out_the_default_redirects_beside_a_kept_variable(partial_default_client): + """Test that the URL naming the default value still redirects to the short one.""" + response = partial_default_client.get("/global/staff/help/home/") + + assert response.status_code == 308 + assert urlsplit(response.location).path == "/staff/help/home/" + + +def test_a_default_naming_no_variable_of_the_prefix_is_ignored(tmp_path_factory): + """Test that a default the prefix carries no variable for serves nothing new.""" + client = make_app( + tmp_path_factory, + WIKI_URL_PREFIX="//help", + WIKI_URL_PREFIX_DEFAULTS={"typo": "global"}, + SERVER_NAME=None, + ).test_client() + + assert client.get("/hepvs/help/home/").status_code == 200 + assert client.get("/help/home/").status_code == 404