From 4d7425ef7298db0fd5d96c4d0d244f79ef232364 Mon Sep 17 00:00:00 2001 From: Martin Castro Laminrs Date: Thu, 28 May 2026 15:28:14 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20API=5FURL=5FPREFIX=20setting=20?= =?UTF-8?q?=E2=80=94=20point=20the=20SPA=20at=20a=20separately-mounted=20A?= =?UTF-8?q?PI=20(#559)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Today the SPA reaches the JSON API by an *implicit* convention: `django_admin_react.urls` inline-includes `django-admin-rest-api` at `/api/v1/`, and the SPA derives its API base from `request.path`. That's plug-and-play but locks the consumer into the assumption that the API lives **under** the SPA's mount. A new optional `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` setting lets a consumer point the SPA at a separately-mounted `django-admin-rest-api` so the API can be a single shared mount (also reachable by `django-admin-mcp-api` or non-browser clients), and the SPA hits **that** URL instead of the inline include. ## What changes - `conf.py`: new `"API_URL_PREFIX": None` default. Unset → today's behaviour, unchanged. - `urls.py`: when `API_URL_PREFIX` is set, the inline `include("django_admin_rest_api.api.urls")` is **skipped** so there's no double-mount. - `views.py`: `_resolve_api_prefix(request)` resolves the prefix (consumer override → trailing-slash-normalized; else `/api/v1/`), injected into the SPA template. - `templates/admin_react/index.html`: new ``. - Frontend `main.tsx`: reads the meta + passes `apiPrefix` to the `ApiClient`. - Frontend `client.ts`: `ApiClient` accepts optional `apiPrefix` and uses it to build every URL; defaults to `api/v1/` so consumers who don't set the override see no change. ## Tests - Backend (`test_spa_index.py`): default → meta is `/api/v1/`; override → meta is the override verbatim; missing-trailing-slash → resolver adds one. Full suite **45 passed**. - Frontend (`client.test.ts`): override routes every request through it; missing-trailing-slash gets one; omitted prefix keeps the legacy `api/v1/` path. Full vitest **145 passed**. Closes #559 Co-Authored-By: Claude Opus 4.7 (1M context) --- django_admin_react/conf.py | 12 +++++ .../templates/admin_react/index.html | 7 +++ django_admin_react/urls.py | 27 +++++++++--- django_admin_react/views.py | 32 ++++++++++++++ frontend/apps/web/src/main.tsx | 21 ++++++++- frontend/packages/api/src/client.test.ts | 43 ++++++++++++++++++ frontend/packages/api/src/client.ts | 21 +++++++-- tests/test_spa_index.py | 44 +++++++++++++++++++ 8 files changed, 197 insertions(+), 10 deletions(-) diff --git a/django_admin_react/conf.py b/django_admin_react/conf.py index 20e82d4..421f190 100644 --- a/django_admin_react/conf.py +++ b/django_admin_react/conf.py @@ -87,6 +87,17 @@ # dicts. ``None`` (default) uses the shipped # 192/512/maskable set under # ``static/dar/icons/``. + # ``API_URL_PREFIX`` — absolute URL prefix the SPA calls for every + # JSON request (#559). Default ``None`` keeps the inline include the + # package ships today (`/api/v1/`), so existing consumers + # are unaffected. Override when the consumer mounts + # ``django_admin_rest_api.urls`` separately and the SPA should talk + # to **that** mount instead — for example + # ``DJANGO_ADMIN_REACT = {"API_URL_PREFIX": "/api/api/v1/"}`` lets + # the SPA and any other client share a single REST mount. When set, + # `django_admin_react.urls` skips the inline `api/v1/` include so + # there is no double-mount. + "API_URL_PREFIX": None, "PWA_NAME": None, "PWA_SHORT_NAME": None, "PWA_ICONS": None, @@ -109,6 +120,7 @@ class _PackageSettings: BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"] PRIMARY_COLOR: str = DEFAULTS["PRIMARY_COLOR"] REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"] + API_URL_PREFIX: str | None = DEFAULTS["API_URL_PREFIX"] PWA_NAME: str | None = DEFAULTS["PWA_NAME"] PWA_SHORT_NAME: str | None = DEFAULTS["PWA_SHORT_NAME"] PWA_ICONS: list[dict[str, str]] | None = DEFAULTS["PWA_ICONS"] diff --git a/django_admin_react/templates/admin_react/index.html b/django_admin_react/templates/admin_react/index.html index 2278fb5..9505eb5 100644 --- a/django_admin_react/templates/admin_react/index.html +++ b/django_admin_react/templates/admin_react/index.html @@ -10,6 +10,13 @@ + {% comment %} + Absolute URL prefix the SPA calls for every JSON request (#559). + Defaults to `/api/v1/` (the inline include behaviour); a + consumer can override via `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` + to point the SPA at a separately-mounted django-admin-rest-api. + {% endcomment %} + {% if brand_logo_url %}{% endif %} {% comment %} diff --git a/django_admin_react/urls.py b/django_admin_react/urls.py index c2d7002..d231468 100644 --- a/django_admin_react/urls.py +++ b/django_admin_react/urls.py @@ -29,18 +29,33 @@ from django.urls import path from django.urls import re_path +from django_admin_react import conf as dar_conf from django_admin_react import pwa from django_admin_react import views app_name = "django_admin_react" +# Inline-mount the API under this package's prefix unless the consumer +# has explicitly told the SPA to talk to a separately-mounted API via +# `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` (#559). When the override is +# set, the consumer is responsible for mounting +# `django_admin_rest_api.urls` themselves at that prefix — including the +# API here too would double-mount it and cause routing collisions. +_inline_api: list = ( + [] + if dar_conf.API_URL_PREFIX is not None + else [ + # API endpoints — implemented by the sibling `django-admin-rest-api` + # package, included here at the same `api/v1/` prefix the SPA already + # expects. No URL namespace: the SPA builds these URLs from the wire + # contract, not via Django's `reverse()`, so a namespace would be + # dead weight. + path("api/v1/", include("django_admin_rest_api.api.urls")), + ] +) + urlpatterns: list = [ - # API endpoints — implemented by the sibling `django-admin-rest-api` - # package, included here at the same `api/v1/` prefix the SPA already - # expects. No URL namespace: the SPA builds these URLs from the wire - # contract, not via Django's `reverse()`, so a namespace would be - # dead weight. - path("api/v1/", include("django_admin_rest_api.api.urls")), + *_inline_api, # The package's own login / logout. These let the package replace # the legacy admin's login when the consumer turns # ``django.contrib.admin`` off — ``SpaIndexView`` falls back to diff --git a/django_admin_react/views.py b/django_admin_react/views.py index c486431..4a413a6 100644 --- a/django_admin_react/views.py +++ b/django_admin_react/views.py @@ -99,6 +99,12 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse: "admin_react/index.html", { "mount_point": _mount_from_request(request), + # Absolute URL prefix the SPA will use for every JSON + # request (#559). Defaults to `/api/v1/` (the + # inline-include path); a consumer can override via + # `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` to point the + # SPA at a separately-mounted `django-admin-rest-api`. + "api_prefix": _resolve_api_prefix(request), "bundle": _load_manifest_entry(), "brand_title": _resolve_brand_title(admin_site), "tab_title": _resolve_tab_title(admin_site), @@ -337,6 +343,32 @@ def _resolve_initial_theme(request: HttpRequest) -> str | None: return theme if theme in ("light", "dark") else None +def _resolve_api_prefix(request: HttpRequest) -> str: + """Absolute URL prefix the SPA should call for JSON requests (#559). + + Priority: + + 1. ``DJANGO_ADMIN_REACT["API_URL_PREFIX"]`` — explicit consumer + override, used verbatim when the consumer mounts + ``django_admin_rest_api.urls`` at their own prefix (so the SPA + can talk to a single shared API mount). The package's own + ``urls.py`` will *not* inline the API in that case (no double- + mount). + 2. Default — ``/api/v1/`` where ```` is the SPA's + configured mount point (the same path the inline include + registers). Existing consumers see no change. + + The returned string always ends in a trailing slash so the SPA can + concat endpoint paths without thinking about it. + """ + override = dar_conf.API_URL_PREFIX + if isinstance(override, str) and override: + return override if override.endswith("/") else override + "/" + mount = _mount_from_request(request) + # ``mount`` always ends in "/", so concatenation is safe. + return f"{mount}api/v1/" + + def _mount_from_request(request: HttpRequest) -> str: """Reconstruct the consumer-chosen mount prefix from ``request.path``. diff --git a/frontend/apps/web/src/main.tsx b/frontend/apps/web/src/main.tsx index 70a476f..4c54c62 100644 --- a/frontend/apps/web/src/main.tsx +++ b/frontend/apps/web/src/main.tsx @@ -38,6 +38,25 @@ function detectMount(): string { const mount = detectMount(); +// API URL prefix (#559) — absolute prefix the API client uses for every +// JSON request. The backend's `SpaIndexView` writes it to the +// `index.html` template as ``; +// defaults to `api/v1/` (the inline-include path the package +// ships today), but a consumer can override it via +// `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` so the SPA talks to a +// separately-mounted `django-admin-rest-api`. Falls back to the +// `api/v1/` derivation when the meta is absent (older templates, +// dev, tests). +function detectApiPrefix(): string { + if (typeof document !== 'undefined') { + const meta = document.querySelector('meta[name="dar-api-prefix"]'); + if (meta?.content) return meta.content; + } + return `${mount}api/v1/`; +} + +const apiPrefix = detectApiPrefix(); + // Mid-session auth loss (#414): when a request returns a session-level // auth failure (401, or a 403 flagged `session_expired`), the operator's // session is gone — they must not be dead-ended on an error screen. A @@ -54,7 +73,7 @@ function handleAuthFailure(): void { window.location.assign(window.location.href); } -const client = new ApiClient({ mount, onAuthFailure: handleAuthFailure }); +const client = new ApiClient({ mount, apiPrefix, onAuthFailure: handleAuthFailure }); // PWA (#86): register the hand-rolled service worker the package serves // at `sw.js`, scoped to the mount so it never claims sibling diff --git a/frontend/packages/api/src/client.test.ts b/frontend/packages/api/src/client.test.ts index 691d8e8..18e1958 100644 --- a/frontend/packages/api/src/client.test.ts +++ b/frontend/packages/api/src/client.test.ts @@ -96,3 +96,46 @@ describe('ApiClient.getRecentActions (#502)', () => { expect(calls[0]?.[0]).toBe('/admin-react/api/v1/recent-actions/?limit=5'); }); }); + +describe('ApiClient — apiPrefix override (#559)', () => { + it('routes every request through `apiPrefix` instead of `api/v1/`', async () => { + const fetchImpl = vi.fn( + async () => fakeResponse(200, { actions: [] }), + ) as unknown as typeof fetch; + // The consumer mounted django-admin-rest-api at /api/ separately + // and set DJANGO_ADMIN_REACT["API_URL_PREFIX"] = "/api/api/v1/" so + // the SPA talks to that mount instead of /api/v1/. + const client = new ApiClient({ + mount: '/admin-react/', + apiPrefix: '/api/api/v1/', + fetchImpl, + }); + await client.getRecentActions(); + const calls = (fetchImpl as unknown as ReturnType).mock.calls; + expect(calls[0]?.[0]).toBe('/api/api/v1/recent-actions/'); + }); + + it('adds a trailing slash to `apiPrefix` when missing (concat invariant)', async () => { + const fetchImpl = vi.fn( + async () => fakeResponse(200, { actions: [] }), + ) as unknown as typeof fetch; + const client = new ApiClient({ + mount: '/admin-react/', + apiPrefix: '/custom-api/v1', + fetchImpl, + }); + await client.getRecentActions(); + const calls = (fetchImpl as unknown as ReturnType).mock.calls; + expect(calls[0]?.[0]).toBe('/custom-api/v1/recent-actions/'); + }); + + it('defaults to `api/v1/` when `apiPrefix` is omitted (no change for existing consumers)', async () => { + const fetchImpl = vi.fn( + async () => fakeResponse(200, { actions: [] }), + ) as unknown as typeof fetch; + const client = new ApiClient({ mount: '/admin-react/', fetchImpl }); + await client.getRecentActions(); + const calls = (fetchImpl as unknown as ReturnType).mock.calls; + expect(calls[0]?.[0]).toBe('/admin-react/api/v1/recent-actions/'); + }); +}); diff --git a/frontend/packages/api/src/client.ts b/frontend/packages/api/src/client.ts index ba1c27c..8d060bf 100644 --- a/frontend/packages/api/src/client.ts +++ b/frontend/packages/api/src/client.ts @@ -27,10 +27,18 @@ import type { export interface ApiClientConfig { /** * Absolute path the package is mounted at, e.g. `/admin-react/`. - * Reported by the backend in the `registry` response and reused as - * the base for all subsequent calls. + * Used as the router basename and as the default API-prefix base when + * `apiPrefix` is not supplied. */ mount: string; + /** + * Absolute URL prefix for every JSON request (#559). Lets the consumer + * point the SPA at a separately-mounted `django-admin-rest-api` + * instead of the inline `api/v1/` include. When omitted, falls + * back to `api/v1/` — the historical behaviour, unchanged for + * existing consumers. + */ + apiPrefix?: string; /** * Fetch implementation; defaults to global fetch. Overridable for * tests. @@ -69,12 +77,19 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); export class ApiClient { private readonly mount: string; + private readonly apiPrefix: string; private readonly fetchImpl: typeof fetch; private readonly csrfCookieName: string; private readonly onAuthFailure: (() => void) | undefined; constructor(config: ApiClientConfig) { this.mount = config.mount.endsWith('/') ? config.mount : `${config.mount}/`; + // Default the API prefix to `api/v1/` so behaviour is + // unchanged for consumers who don't set `DJANGO_ADMIN_REACT + // ["API_URL_PREFIX"]` (#559). Always ends with "/" so concat is + // safe in `url()`. + const rawPrefix = config.apiPrefix ?? `${this.mount}api/v1/`; + this.apiPrefix = rawPrefix.endsWith('/') ? rawPrefix : `${rawPrefix}/`; this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis); this.csrfCookieName = config.csrfCookieName ?? 'csrftoken'; this.onAuthFailure = config.onAuthFailure; @@ -93,7 +108,7 @@ export class ApiClient { private url(path: string): string { const trimmed = path.startsWith('/') ? path.slice(1) : path; - return `${this.mount}api/v1/${trimmed}`; + return `${this.apiPrefix}${trimmed}`; } private csrfToken(): string | null { diff --git a/tests/test_spa_index.py b/tests/test_spa_index.py index f17799e..287fdf9 100644 --- a/tests/test_spa_index.py +++ b/tests/test_spa_index.py @@ -120,6 +120,50 @@ def test_mount_meta_tag_reflects_url(superuser_client: Client) -> None: assert 'content="/admin-react/"' in body +@pytest.mark.django_db +def test_api_prefix_meta_defaults_to_mount_plus_api_v1(superuser_client: Client) -> None: + """Default (no `API_URL_PREFIX` override): the `dar-api-prefix` meta + is `/api/v1/` — the inline-include URL the package already + serves, unchanged from before #559.""" + response = superuser_client.get(ROOT_URL) + assert response.status_code == 200 + body = response.content.decode("utf-8") + assert 'name="dar-api-prefix"' in body + assert 'content="/admin-react/api/v1/"' in body + + +@pytest.mark.django_db +def test_api_prefix_meta_honours_override(superuser_client: Client) -> None: + """With `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` set, the `dar-api-prefix` + meta carries that value verbatim — the SPA will call that URL instead + of the inline mount (#559).""" + with override_settings(DJANGO_ADMIN_REACT={"API_URL_PREFIX": "/api/api/v1/"}): + _reload_conf() + try: + response = superuser_client.get(ROOT_URL) + assert response.status_code == 200 + body = response.content.decode("utf-8") + assert 'name="dar-api-prefix"' in body + assert 'content="/api/api/v1/"' in body + finally: + _reload_conf() + + +@pytest.mark.django_db +def test_api_prefix_meta_adds_trailing_slash_when_missing(superuser_client: Client) -> None: + """Trailing slash invariant (#559): the SPA appends endpoint paths to + the prefix, so the resolver always ensures one slash at the end even + if the consumer's override omitted it.""" + with override_settings(DJANGO_ADMIN_REACT={"API_URL_PREFIX": "/custom-api/v1"}): + _reload_conf() + try: + response = superuser_client.get(ROOT_URL) + body = response.content.decode("utf-8") + assert 'content="/custom-api/v1/"' in body + finally: + _reload_conf() + + # --------------------------------------------------------------------------- # # Bundle wiring # # --------------------------------------------------------------------------- #