Skip to content

Commit 4d7425e

Browse files
feat: API_URL_PREFIX setting — point the SPA at a separately-mounted API (#559)
Today the SPA reaches the JSON API by an *implicit* convention: `django_admin_react.urls` inline-includes `django-admin-rest-api` at `<spa-mount>/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 `<mount>/api/v1/`), injected into the SPA template. - `templates/admin_react/index.html`: new `<meta name="dar-api-prefix" content="…">`. - 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 `<mount>api/v1/` so consumers who don't set the override see no change. ## Tests - Backend (`test_spa_index.py`): default → meta is `<mount>/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 `<mount>api/v1/` path. Full vitest **145 passed**. Closes #559 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 418a730 commit 4d7425e

8 files changed

Lines changed: 197 additions & 10 deletions

File tree

django_admin_react/conf.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@
8787
# dicts. ``None`` (default) uses the shipped
8888
# 192/512/maskable set under
8989
# ``static/dar/icons/``.
90+
# ``API_URL_PREFIX`` — absolute URL prefix the SPA calls for every
91+
# JSON request (#559). Default ``None`` keeps the inline include the
92+
# package ships today (`<spa-mount>/api/v1/`), so existing consumers
93+
# are unaffected. Override when the consumer mounts
94+
# ``django_admin_rest_api.urls`` separately and the SPA should talk
95+
# to **that** mount instead — for example
96+
# ``DJANGO_ADMIN_REACT = {"API_URL_PREFIX": "/api/api/v1/"}`` lets
97+
# the SPA and any other client share a single REST mount. When set,
98+
# `django_admin_react.urls` skips the inline `api/v1/` include so
99+
# there is no double-mount.
100+
"API_URL_PREFIX": None,
90101
"PWA_NAME": None,
91102
"PWA_SHORT_NAME": None,
92103
"PWA_ICONS": None,
@@ -109,6 +120,7 @@ class _PackageSettings:
109120
BRAND_LOGO_URL: str | None = DEFAULTS["BRAND_LOGO_URL"]
110121
PRIMARY_COLOR: str = DEFAULTS["PRIMARY_COLOR"]
111122
REACT_LOGIN: bool = DEFAULTS["REACT_LOGIN"]
123+
API_URL_PREFIX: str | None = DEFAULTS["API_URL_PREFIX"]
112124
PWA_NAME: str | None = DEFAULTS["PWA_NAME"]
113125
PWA_SHORT_NAME: str | None = DEFAULTS["PWA_SHORT_NAME"]
114126
PWA_ICONS: list[dict[str, str]] | None = DEFAULTS["PWA_ICONS"]

django_admin_react/templates/admin_react/index.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
<meta charset="utf-8" />
1111
<meta name="viewport" content="width=device-width, initial-scale=1" />
1212
<meta name="dar-mount" content="{{ mount_point }}" />
13+
{% comment %}
14+
Absolute URL prefix the SPA calls for every JSON request (#559).
15+
Defaults to `<mount>/api/v1/` (the inline include behaviour); a
16+
consumer can override via `DJANGO_ADMIN_REACT["API_URL_PREFIX"]`
17+
to point the SPA at a separately-mounted django-admin-rest-api.
18+
{% endcomment %}
19+
<meta name="dar-api-prefix" content="{{ api_prefix }}" />
1320
<meta name="dar-brand-title" content="{{ brand_title }}" />
1421
{% if brand_logo_url %}<meta name="dar-brand-logo" content="{{ brand_logo_url }}" />{% endif %}
1522
{% comment %}

django_admin_react/urls.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,18 +29,33 @@
2929
from django.urls import path
3030
from django.urls import re_path
3131

32+
from django_admin_react import conf as dar_conf
3233
from django_admin_react import pwa
3334
from django_admin_react import views
3435

3536
app_name = "django_admin_react"
3637

38+
# Inline-mount the API under this package's prefix unless the consumer
39+
# has explicitly told the SPA to talk to a separately-mounted API via
40+
# `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` (#559). When the override is
41+
# set, the consumer is responsible for mounting
42+
# `django_admin_rest_api.urls` themselves at that prefix — including the
43+
# API here too would double-mount it and cause routing collisions.
44+
_inline_api: list = (
45+
[]
46+
if dar_conf.API_URL_PREFIX is not None
47+
else [
48+
# API endpoints — implemented by the sibling `django-admin-rest-api`
49+
# package, included here at the same `api/v1/` prefix the SPA already
50+
# expects. No URL namespace: the SPA builds these URLs from the wire
51+
# contract, not via Django's `reverse()`, so a namespace would be
52+
# dead weight.
53+
path("api/v1/", include("django_admin_rest_api.api.urls")),
54+
]
55+
)
56+
3757
urlpatterns: list = [
38-
# API endpoints — implemented by the sibling `django-admin-rest-api`
39-
# package, included here at the same `api/v1/` prefix the SPA already
40-
# expects. No URL namespace: the SPA builds these URLs from the wire
41-
# contract, not via Django's `reverse()`, so a namespace would be
42-
# dead weight.
43-
path("api/v1/", include("django_admin_rest_api.api.urls")),
58+
*_inline_api,
4459
# The package's own login / logout. These let the package replace
4560
# the legacy admin's login when the consumer turns
4661
# ``django.contrib.admin`` off — ``SpaIndexView`` falls back to

django_admin_react/views.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
9999
"admin_react/index.html",
100100
{
101101
"mount_point": _mount_from_request(request),
102+
# Absolute URL prefix the SPA will use for every JSON
103+
# request (#559). Defaults to `<mount>/api/v1/` (the
104+
# inline-include path); a consumer can override via
105+
# `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` to point the
106+
# SPA at a separately-mounted `django-admin-rest-api`.
107+
"api_prefix": _resolve_api_prefix(request),
102108
"bundle": _load_manifest_entry(),
103109
"brand_title": _resolve_brand_title(admin_site),
104110
"tab_title": _resolve_tab_title(admin_site),
@@ -337,6 +343,32 @@ def _resolve_initial_theme(request: HttpRequest) -> str | None:
337343
return theme if theme in ("light", "dark") else None
338344

339345

346+
def _resolve_api_prefix(request: HttpRequest) -> str:
347+
"""Absolute URL prefix the SPA should call for JSON requests (#559).
348+
349+
Priority:
350+
351+
1. ``DJANGO_ADMIN_REACT["API_URL_PREFIX"]`` — explicit consumer
352+
override, used verbatim when the consumer mounts
353+
``django_admin_rest_api.urls`` at their own prefix (so the SPA
354+
can talk to a single shared API mount). The package's own
355+
``urls.py`` will *not* inline the API in that case (no double-
356+
mount).
357+
2. Default — ``<mount>/api/v1/`` where ``<mount>`` is the SPA's
358+
configured mount point (the same path the inline include
359+
registers). Existing consumers see no change.
360+
361+
The returned string always ends in a trailing slash so the SPA can
362+
concat endpoint paths without thinking about it.
363+
"""
364+
override = dar_conf.API_URL_PREFIX
365+
if isinstance(override, str) and override:
366+
return override if override.endswith("/") else override + "/"
367+
mount = _mount_from_request(request)
368+
# ``mount`` always ends in "/", so concatenation is safe.
369+
return f"{mount}api/v1/"
370+
371+
340372
def _mount_from_request(request: HttpRequest) -> str:
341373
"""Reconstruct the consumer-chosen mount prefix from ``request.path``.
342374

frontend/apps/web/src/main.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ function detectMount(): string {
3838

3939
const mount = detectMount();
4040

41+
// API URL prefix (#559) — absolute prefix the API client uses for every
42+
// JSON request. The backend's `SpaIndexView` writes it to the
43+
// `index.html` template as `<meta name="dar-api-prefix" content="...">`;
44+
// defaults to `<mount>api/v1/` (the inline-include path the package
45+
// ships today), but a consumer can override it via
46+
// `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` so the SPA talks to a
47+
// separately-mounted `django-admin-rest-api`. Falls back to the
48+
// `<mount>api/v1/` derivation when the meta is absent (older templates,
49+
// dev, tests).
50+
function detectApiPrefix(): string {
51+
if (typeof document !== 'undefined') {
52+
const meta = document.querySelector<HTMLMetaElement>('meta[name="dar-api-prefix"]');
53+
if (meta?.content) return meta.content;
54+
}
55+
return `${mount}api/v1/`;
56+
}
57+
58+
const apiPrefix = detectApiPrefix();
59+
4160
// Mid-session auth loss (#414): when a request returns a session-level
4261
// auth failure (401, or a 403 flagged `session_expired`), the operator's
4362
// session is gone — they must not be dead-ended on an error screen. A
@@ -54,7 +73,7 @@ function handleAuthFailure(): void {
5473
window.location.assign(window.location.href);
5574
}
5675

57-
const client = new ApiClient({ mount, onAuthFailure: handleAuthFailure });
76+
const client = new ApiClient({ mount, apiPrefix, onAuthFailure: handleAuthFailure });
5877

5978
// PWA (#86): register the hand-rolled service worker the package serves
6079
// at `<mount>sw.js`, scoped to the mount so it never claims sibling

frontend/packages/api/src/client.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,46 @@ describe('ApiClient.getRecentActions (#502)', () => {
9696
expect(calls[0]?.[0]).toBe('/admin-react/api/v1/recent-actions/?limit=5');
9797
});
9898
});
99+
100+
describe('ApiClient — apiPrefix override (#559)', () => {
101+
it('routes every request through `apiPrefix` instead of `<mount>api/v1/`', async () => {
102+
const fetchImpl = vi.fn(
103+
async () => fakeResponse(200, { actions: [] }),
104+
) as unknown as typeof fetch;
105+
// The consumer mounted django-admin-rest-api at /api/ separately
106+
// and set DJANGO_ADMIN_REACT["API_URL_PREFIX"] = "/api/api/v1/" so
107+
// the SPA talks to that mount instead of <spa-mount>/api/v1/.
108+
const client = new ApiClient({
109+
mount: '/admin-react/',
110+
apiPrefix: '/api/api/v1/',
111+
fetchImpl,
112+
});
113+
await client.getRecentActions();
114+
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls;
115+
expect(calls[0]?.[0]).toBe('/api/api/v1/recent-actions/');
116+
});
117+
118+
it('adds a trailing slash to `apiPrefix` when missing (concat invariant)', async () => {
119+
const fetchImpl = vi.fn(
120+
async () => fakeResponse(200, { actions: [] }),
121+
) as unknown as typeof fetch;
122+
const client = new ApiClient({
123+
mount: '/admin-react/',
124+
apiPrefix: '/custom-api/v1',
125+
fetchImpl,
126+
});
127+
await client.getRecentActions();
128+
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls;
129+
expect(calls[0]?.[0]).toBe('/custom-api/v1/recent-actions/');
130+
});
131+
132+
it('defaults to `<mount>api/v1/` when `apiPrefix` is omitted (no change for existing consumers)', async () => {
133+
const fetchImpl = vi.fn(
134+
async () => fakeResponse(200, { actions: [] }),
135+
) as unknown as typeof fetch;
136+
const client = new ApiClient({ mount: '/admin-react/', fetchImpl });
137+
await client.getRecentActions();
138+
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls;
139+
expect(calls[0]?.[0]).toBe('/admin-react/api/v1/recent-actions/');
140+
});
141+
});

frontend/packages/api/src/client.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,18 @@ import type {
2727
export interface ApiClientConfig {
2828
/**
2929
* Absolute path the package is mounted at, e.g. `/admin-react/`.
30-
* Reported by the backend in the `registry` response and reused as
31-
* the base for all subsequent calls.
30+
* Used as the router basename and as the default API-prefix base when
31+
* `apiPrefix` is not supplied.
3232
*/
3333
mount: string;
34+
/**
35+
* Absolute URL prefix for every JSON request (#559). Lets the consumer
36+
* point the SPA at a separately-mounted `django-admin-rest-api`
37+
* instead of the inline `<mount>api/v1/` include. When omitted, falls
38+
* back to `<mount>api/v1/` — the historical behaviour, unchanged for
39+
* existing consumers.
40+
*/
41+
apiPrefix?: string;
3442
/**
3543
* Fetch implementation; defaults to global fetch. Overridable for
3644
* tests.
@@ -69,12 +77,19 @@ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
6977

7078
export class ApiClient {
7179
private readonly mount: string;
80+
private readonly apiPrefix: string;
7281
private readonly fetchImpl: typeof fetch;
7382
private readonly csrfCookieName: string;
7483
private readonly onAuthFailure: (() => void) | undefined;
7584

7685
constructor(config: ApiClientConfig) {
7786
this.mount = config.mount.endsWith('/') ? config.mount : `${config.mount}/`;
87+
// Default the API prefix to `<mount>api/v1/` so behaviour is
88+
// unchanged for consumers who don't set `DJANGO_ADMIN_REACT
89+
// ["API_URL_PREFIX"]` (#559). Always ends with "/" so concat is
90+
// safe in `url()`.
91+
const rawPrefix = config.apiPrefix ?? `${this.mount}api/v1/`;
92+
this.apiPrefix = rawPrefix.endsWith('/') ? rawPrefix : `${rawPrefix}/`;
7893
this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
7994
this.csrfCookieName = config.csrfCookieName ?? 'csrftoken';
8095
this.onAuthFailure = config.onAuthFailure;
@@ -93,7 +108,7 @@ export class ApiClient {
93108

94109
private url(path: string): string {
95110
const trimmed = path.startsWith('/') ? path.slice(1) : path;
96-
return `${this.mount}api/v1/${trimmed}`;
111+
return `${this.apiPrefix}${trimmed}`;
97112
}
98113

99114
private csrfToken(): string | null {

tests/test_spa_index.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,50 @@ def test_mount_meta_tag_reflects_url(superuser_client: Client) -> None:
120120
assert 'content="/admin-react/"' in body
121121

122122

123+
@pytest.mark.django_db
124+
def test_api_prefix_meta_defaults_to_mount_plus_api_v1(superuser_client: Client) -> None:
125+
"""Default (no `API_URL_PREFIX` override): the `dar-api-prefix` meta
126+
is `<mount>/api/v1/` — the inline-include URL the package already
127+
serves, unchanged from before #559."""
128+
response = superuser_client.get(ROOT_URL)
129+
assert response.status_code == 200
130+
body = response.content.decode("utf-8")
131+
assert 'name="dar-api-prefix"' in body
132+
assert 'content="/admin-react/api/v1/"' in body
133+
134+
135+
@pytest.mark.django_db
136+
def test_api_prefix_meta_honours_override(superuser_client: Client) -> None:
137+
"""With `DJANGO_ADMIN_REACT["API_URL_PREFIX"]` set, the `dar-api-prefix`
138+
meta carries that value verbatim — the SPA will call that URL instead
139+
of the inline mount (#559)."""
140+
with override_settings(DJANGO_ADMIN_REACT={"API_URL_PREFIX": "/api/api/v1/"}):
141+
_reload_conf()
142+
try:
143+
response = superuser_client.get(ROOT_URL)
144+
assert response.status_code == 200
145+
body = response.content.decode("utf-8")
146+
assert 'name="dar-api-prefix"' in body
147+
assert 'content="/api/api/v1/"' in body
148+
finally:
149+
_reload_conf()
150+
151+
152+
@pytest.mark.django_db
153+
def test_api_prefix_meta_adds_trailing_slash_when_missing(superuser_client: Client) -> None:
154+
"""Trailing slash invariant (#559): the SPA appends endpoint paths to
155+
the prefix, so the resolver always ensures one slash at the end even
156+
if the consumer's override omitted it."""
157+
with override_settings(DJANGO_ADMIN_REACT={"API_URL_PREFIX": "/custom-api/v1"}):
158+
_reload_conf()
159+
try:
160+
response = superuser_client.get(ROOT_URL)
161+
body = response.content.decode("utf-8")
162+
assert 'content="/custom-api/v1/"' in body
163+
finally:
164+
_reload_conf()
165+
166+
123167
# --------------------------------------------------------------------------- #
124168
# Bundle wiring #
125169
# --------------------------------------------------------------------------- #

0 commit comments

Comments
 (0)