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