Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions django_admin_react/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<spa-mount>/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,
Expand All @@ -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"]
Expand Down
7 changes: 7 additions & 0 deletions django_admin_react/templates/admin_react/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="dar-mount" content="{{ mount_point }}" />
{% comment %}
Absolute URL prefix the SPA calls for every JSON request (#559).
Defaults to `<mount>/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 %}
<meta name="dar-api-prefix" content="{{ api_prefix }}" />
<meta name="dar-brand-title" content="{{ brand_title }}" />
{% if brand_logo_url %}<meta name="dar-brand-logo" content="{{ brand_logo_url }}" />{% endif %}
{% comment %}
Expand Down
27 changes: 21 additions & 6 deletions django_admin_react/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions django_admin_react/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mount>/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),
Expand Down Expand Up @@ -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 — ``<mount>/api/v1/`` where ``<mount>`` 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``.

Expand Down
21 changes: 20 additions & 1 deletion frontend/apps/web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<meta name="dar-api-prefix" content="...">`;
// defaults to `<mount>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
// `<mount>api/v1/` derivation when the meta is absent (older templates,
// dev, tests).
function detectApiPrefix(): string {
if (typeof document !== 'undefined') {
const meta = document.querySelector<HTMLMetaElement>('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
Expand All @@ -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 `<mount>sw.js`, scoped to the mount so it never claims sibling
Expand Down
43 changes: 43 additions & 0 deletions frontend/packages/api/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mount>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 <spa-mount>/api/v1/.
const client = new ApiClient({
mount: '/admin-react/',
apiPrefix: '/api/api/v1/',
fetchImpl,
});
await client.getRecentActions();
const calls = (fetchImpl as unknown as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mock.calls;
expect(calls[0]?.[0]).toBe('/custom-api/v1/recent-actions/');
});

it('defaults to `<mount>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<typeof vi.fn>).mock.calls;
expect(calls[0]?.[0]).toBe('/admin-react/api/v1/recent-actions/');
});
});
21 changes: 18 additions & 3 deletions frontend/packages/api/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mount>api/v1/` include. When omitted, falls
* back to `<mount>api/v1/` — the historical behaviour, unchanged for
* existing consumers.
*/
apiPrefix?: string;
/**
* Fetch implementation; defaults to global fetch. Overridable for
* tests.
Expand Down Expand Up @@ -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 `<mount>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;
Expand All @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions tests/test_spa_index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<mount>/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 #
# --------------------------------------------------------------------------- #
Expand Down
Loading