diff --git a/datasette/url_builder.py b/datasette/url_builder.py index c1bf629b..bcc4f39d 100644 --- a/datasette/url_builder.py +++ b/datasette/url_builder.py @@ -1,4 +1,4 @@ -from .utils import path_with_format, HASH_LENGTH +from .utils import path_with_format, HASH_LENGTH, PrefixedUrlString import urllib @@ -7,12 +7,13 @@ class Urls: self.ds = ds def path(self, path, format=None): - if path.startswith("/"): - path = path[1:] - path = self.ds.config("base_url") + path + if not isinstance(path, PrefixedUrlString): + if path.startswith("/"): + path = path[1:] + path = self.ds.config("base_url") + path if format is not None: path = path_with_format(path=path, format=format) - return path + return PrefixedUrlString(path) def instance(self, format=None): return self.path("", format=format) @@ -40,19 +41,19 @@ class Urls: path = "{}/{}".format(self.database(database), urllib.parse.quote_plus(table)) if format is not None: path = path_with_format(path=path, format=format) - return path + return PrefixedUrlString(path) def query(self, database, query, format=None): path = "{}/{}".format(self.database(database), urllib.parse.quote_plus(query)) if format is not None: path = path_with_format(path=path, format=format) - return path + return PrefixedUrlString(path) def row(self, database, table, row_path, format=None): path = "{}/{}".format(self.table(database, table), row_path) if format is not None: path = path_with_format(path=path, format=format) - return path + return PrefixedUrlString(path) def row_blob(self, database, table, row_path, column): return self.table(database, table) + "/{}.blob?_blob_column={}".format( diff --git a/datasette/utils/__init__.py b/datasette/utils/__init__.py index bf361784..21fa944c 100644 --- a/datasette/utils/__init__.py +++ b/datasette/utils/__init__.py @@ -1010,3 +1010,26 @@ async def initial_path_for_datasette(datasette): else: path = datasette.urls.instance() return path + + +class PrefixedUrlString(str): + def __add__(self, other): + return type(self)(super().__add__(other)) + + def __getattribute__(self, name): + if name in dir(str): + + def method(self, *args, **kwargs): + value = getattr(super(), name)(*args, **kwargs) + if isinstance(value, str): + return type(self)(value) + elif isinstance(value, list): + return [type(self)(i) for i in value] + elif isinstance(value, tuple): + return tuple(type(self)(i) for i in value) + else: + return value + + return method.__get__(self) + else: + return super().__getattribute__(name) diff --git a/docs/internals.rst b/docs/internals.rst index ee7fe6e4..8594e36a 100644 --- a/docs/internals.rst +++ b/docs/internals.rst @@ -443,6 +443,8 @@ These functions can be accessed via the ``{{ urls }}`` object in Datasette templ Use the ``format="json"`` (or ``"csv"`` or other formats supported by plugins) arguments to get back URLs to the JSON representation. This is usually the path with ``.json`` added on the end, but it may use ``?_format=json`` in cases where the path already includes ``.json``, for example a URL to a table named ``table.json``. +These methods each return a ``datasette.utils.PrefixedUrlString`` object, which is a subclass of the Python ``str`` type. This allows the logic that considers the ``base_url`` setting to detect if that prefix has already been applied to the path. + .. _internals_database: Database class diff --git a/tests/test_internals_urls.py b/tests/test_internals_urls.py index 005903df..a56d735b 100644 --- a/tests/test_internals_urls.py +++ b/tests/test_internals_urls.py @@ -1,4 +1,5 @@ from datasette.app import Datasette +from datasette.utils import PrefixedUrlString from .fixtures import app_client_with_hash import pytest @@ -20,7 +21,17 @@ def ds(): ) def test_path(ds, base_url, path, expected): ds._config["base_url"] = base_url - assert ds.urls.path(path) == expected + actual = ds.urls.path(path) + assert actual == expected + assert isinstance(actual, PrefixedUrlString) + + +def test_path_applied_twice_does_not_double_prefix(ds): + ds._config["base_url"] = "/prefix/" + path = ds.urls.path("/") + assert path == "/prefix/" + path = ds.urls.path(path) + assert path == "/prefix/" @pytest.mark.parametrize( @@ -32,7 +43,9 @@ def test_path(ds, base_url, path, expected): ) def test_instance(ds, base_url, expected): ds._config["base_url"] = base_url - assert ds.urls.instance() == expected + actual = ds.urls.instance() + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize( @@ -44,7 +57,9 @@ def test_instance(ds, base_url, expected): ) def test_static(ds, base_url, file, expected): ds._config["base_url"] = base_url - assert ds.urls.static(file) == expected + actual = ds.urls.static(file) + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize( @@ -66,7 +81,9 @@ def test_static(ds, base_url, file, expected): ) def test_static_plugins(ds, base_url, plugin, file, expected): ds._config["base_url"] = base_url - assert ds.urls.static_plugins(plugin, file) == expected + actual = ds.urls.static_plugins(plugin, file) + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize( @@ -78,7 +95,9 @@ def test_static_plugins(ds, base_url, plugin, file, expected): ) def test_logout(ds, base_url, expected): ds._config["base_url"] = base_url - assert ds.urls.logout() == expected + actual = ds.urls.logout() + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize( @@ -91,7 +110,9 @@ def test_logout(ds, base_url, expected): ) def test_database(ds, base_url, format, expected): ds._config["base_url"] = base_url - assert ds.urls.database(":memory:", format=format) == expected + actual = ds.urls.database(":memory:", format=format) + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize( @@ -105,8 +126,12 @@ def test_database(ds, base_url, format, expected): ) def test_table_and_query(ds, base_url, name, format, expected): ds._config["base_url"] = base_url - assert ds.urls.table(":memory:", name, format=format) == expected - assert ds.urls.query(":memory:", name, format=format) == expected + actual1 = ds.urls.table(":memory:", name, format=format) + assert actual1 == expected + assert isinstance(actual1, PrefixedUrlString) + actual2 = ds.urls.query(":memory:", name, format=format) + assert actual2 == expected + assert isinstance(actual2, PrefixedUrlString) @pytest.mark.parametrize( @@ -119,7 +144,9 @@ def test_table_and_query(ds, base_url, name, format, expected): ) def test_row(ds, base_url, format, expected): ds._config["base_url"] = base_url - assert ds.urls.row(":memory:", "facetable", "1", format=format) == expected + actual = ds.urls.row(":memory:", "facetable", "1", format=format) + assert actual == expected + assert isinstance(actual, PrefixedUrlString) @pytest.mark.parametrize("base_url", ["/", "/prefix/"])