Skip to content

Return Hyperview documents and fragments

New to Hyperview? Start with the Django-to-Hyperview request lifecycle, including a screen-to-fragment example.

Use a full-document response for navigation and a fragment response for an in-place update. dj-hyperview applies the correct media type and validates the final XML against the bundled corrected schema before bytes reach the client. No schema installation extra, callback, or activation setting is required.

Return a complete screen

Use HyperviewTemplateResponse when the endpoint renders a consumer-owned template:

from django.http import HttpRequest

from dj_hyperview import HyperviewTemplateResponse


def home(request: HttpRequest) -> HyperviewTemplateResponse:
    return HyperviewTemplateResponse(request, "screens/home.xml")

The response uses application/vnd.hyperview+xml. The rendered result must be a valid complete HXML document.

Return an update fragment

Hyperview replace, append, and prepend actions expect application/vnd.hyperview_fragment+xml and one bare element:

from django.http import HttpRequest

from dj_hyperview import HyperviewFragmentTemplateResponse


def task_row(request: HttpRequest) -> HyperviewFragmentTemplateResponse:
    return HyperviewFragmentTemplateResponse(
        request,
        "fragments/task-row.xml",
        {"task_id": request.POST["task_id"]},
    )

Use HyperviewFragmentResponse when markup is already available:

from dj_hyperview import HyperviewFragmentResponse


response = HyperviewFragmentResponse(
    '<view xmlns="https://hyperview.org/hyperview" id="saved"><text>Saved</text></view>'
)

A fragment must not be wrapped by doc, navigator, screen, or body. Those roots are valid only in their full-document context and raise TemplateValidationError when returned as a fragment.

Validation and atomic body changes

All four response classes validate the final XML, resource limits, and corrected schema automatically. Fragment classes also enforce the client-safe root restriction. A custom content_type does not disable validation. Content and XML declarations must use UTF-8; an incompatible response charset raises ValueError.

content_type=None means that no media type was given: the response uses the Hyperview media type of its class, not Django's text/html, unless headers already carries a Content-Type, in any letter case, which it keeps. Omitting the argument is different in that case: the default is a media type itself, so Django raises ValueError for it, as for any content_type given beside a Content-Type header. When you pass a Hyperview media type with parameters, the response rebuilds the header with charset=utf-8. A parameter value that is not a plain token, such as profile="mobile app", is quoted and escaped as RFC 9110 requires. A parameter name that is not a token, or a value with a control character, a character above U+00FF, or a trailing backslash, raises ValueError. Other media types are passed through unchanged.

Constructors and later content assignment validate the complete candidate before replacing the body. write() appends to the existing document; writelines() assembles all chunks once and validates the combined result. Neither operation is an XML streaming API. Invalid XML, schema errors, encoding errors, or exceeded limits leave previously committed bytes unchanged.

An empty response is allowed only with status 204, 205, or 304. Those statuses reject nonempty content, including whitespace. HyperviewResponse() with its default 200 status therefore raises TemplateValidationError. Changing a status to conflict with the stored body is rejected when content is read or emitted. These cheap checks do not repeat XML parsing or XSD validation.

Bodyless statuses may retain representation metadata such as ETag and Content-Encoding without a payload. This does not permit nonempty content.

Detect Hyperview clients

Add HyperviewMiddleware when a view needs to know who is asking. It supports sync and async stacks and only reads the request:

MIDDLEWARE = [
    # Your other middleware.
    "dj_hyperview.middleware.HyperviewMiddleware",
]

The middleware sets request.hyperview, a HyperviewRequestDetails that is truthy for a request that claims to come from a Hyperview client. A request counts when it sends the X-Hyperview-Version header, or when its Accept header lists application/vnd.hyperview+xml or application/vnd.hyperview_fragment+xml explicitly and Django considers it acceptable. A bare */* does not count. request.hyperview.version holds the version token, or None when the header is absent or malformed. Both headers are client-controlled, so the result is a claim: use it to choose a representation, never to grant access. Call detect_hyperview_request(request) to get the same answer without the middleware.

from django.http import HttpRequest, HttpResponse
from django.shortcuts import render

from dj_hyperview import HyperviewTemplateResponse


def home(request: HttpRequest) -> HttpResponse:
    if request.hyperview:
        return HyperviewTemplateResponse(request, "screens/home.xml")
    return render(request, "web/home.html")

Caches and Vary

Detection is not content negotiation: the middleware adds no Vary header and changes no response. When one URL returns HXML to Hyperview clients and something else to other callers, a shared cache such as cache_page, a CDN or a reverse proxy can store one representation and serve it to the other. Declare what the view's choice depends on:

from django.utils.cache import patch_vary_headers


def home(request: HttpRequest) -> HttpResponse:
    response = (
        HyperviewTemplateResponse(request, "screens/home.xml")
        if request.hyperview
        else render(request, "web/home.html")
    )
    patch_vary_headers(response, ("Accept", "X-Hyperview-Version"))
    return response

A separate URL for each representation avoids the problem altogether.

Lazy rendering and HEAD requests

Template responses remain unrendered until render(). Invalid output raises before commitment and leaves the response unrendered; accessing its body still raises Django's ContentNotRenderedError. A successful repeated render() is a no-op. rendered_content returns fresh validated text without committing it. For 204, 205, and 304, the template is still rendered once and must return exactly an empty string; content is never silently discarded.

Django post-render callbacks keep their normal order and replacement semantics. Mutations to a Hyperview response remain validated. A callback may instead return an ordinary Django response, which is outside the Hyperview validation guarantee.

HEAD requests validate the same nonempty representation as GET before transport suppression. Lazy responses already receive the request. For eager responses, pass it explicitly so Django can suppress HEAD transport without replacing the validated document with empty XML:

def saved(request):
    return HyperviewFragmentResponse(
        '<text xmlns="https://hyperview.org/hyperview">Saved</text>',
        request=request,
    )

This context does not permit an empty 200 constructor or invalid mutations. The validated representation and its headers, including a supplied Content-Length, are retained when HEAD transport is suppressed. Without HEAD request context, an empty 200 body assignment remains an error.

If Hyperview reports XMLRestrictedElementFound, inspect the initiating action, the response media type, and the first response element together. For an in-place action, return only the element that replaces or extends the target.

Gzip transport middleware

Django's GZipMiddleware remains supported after the document is validated. The response keeps its immutable validated representation separately from gzip transport bytes. It accepts a gzip reassignment only when bounded decompression produces that exact representation: truncated, trailing, concatenated, oversized, or different content is rejected. A gzip constructor is not a validation API.

Django briefly reads the compressed body before setting its encoding header; final iteration and serialization require Content-Encoding: gzip. HEAD can then suppress transport without losing the validated representation. This does not add another XML parse or XSD pass unless the validation contract has changed. The contract is checked again at this handoff, including the extra schema files, because the body has waited for the middleware.

Only gzip (or no encoding, or identity) is supported. Any other Content-Encoding, such as br or zstd, raises TemplateValidationError with code http_encoding and the message "only gzip Content-Encoding is supported" when the response is iterated or serialized, because the guard cannot prove that such a body still carries the validated document. Remove the header, or compress with gzip.

To replace or append XML after compression, first remove Content-Encoding. The normal mutation then validates a complete candidate before atomically clearing gzip transport and its old Content-Length. Invalid updates leave the previous representation and transport bytes intact. response.text decodes transport bytes, matching Django; it does not transparently decompress gzip.

Owned asynchronous SSE responses

Use dj_hyperview.realtime.sse_response(async_events, *, aclose) for transport hints, not HXML or successful-refresh acknowledgements. It returns Django's StreamingHttpResponse, with text/event-stream, Cache-Control: no-store, no-transform and X-Accel-Buffering: no. A stream is normally scoped to one authenticated user, so no cache may store it. Redis is not required to import or use this response adapter.

Wrap the outer ASGI application once with application = realtime_asgi(get_asgi_application()), importing the wrapper from dj_hyperview.realtime. Construct the response inside that active HTTP scope, on the subscription's live event loop. Django sync-only middleware is supported: an adapted view task ending does not end the HTTP request. Outside an active scope construction fails before ownership transfers. The mandatory asynchronous aclose callback owns the subscription and admission permit immediately, before the first iteration. Release admission in finally, even if closing the subscription fails. If construction raises, ownership remains with the caller.

Version-one iteration accepts these closed envelopes:

  • {"event":"invalidate","data":{"version":1,"resources":["tasks"]}}
  • {"event":"resync","data":{"version":1}}
  • {"event":"auth-required","data":{"version":1}}
  • None writes a heartbeat comment, never an event acknowledgement.

Resources are 1–32 distinct logical ASCII names matching [a-z][a-z0-9_-]{0,63}. Data JSON is bounded to 4096 UTF-8 bytes. Extra keys, URLs, HXML, raw model/session identifiers, cursors and unknown event names are not accepted. The application must constrain resource names further and authorize before headers and each event/heartbeat; this adapter does not authenticate users or make delivery durable. It implements no event ID, replay or automatic retry.

Negotiated change metadata

INVALIDATION_VERSIONS == (1, 2) advertises validator support, not client negotiation. The a21 API supports only version one. The validator now also accepts version two, which adds no application adaptation or native acceptance. Applications must negotiate before sending version two and project an exact version-one invalidate to old clients. Control events (resync, auth-required) remain version one.

A version-two invalidate has exactly version: 2, resources, mutation_id and entities. mutation_id is null or 40 lowercase hexadecimal characters. entities is null or exactly {"epoch": "<16 lowercase hex>", "items": [...]}; each of 1–32 distinct items contains only resource and a 64-lowercase-hex key. The item's resource must appear in resources. The same 4096-byte limits apply; 32 long resource names plus keys may exceed that bound and are rejected.

Applications generate opaque keys, never public raw primary keys or session identifiers. Unknown entity sets, key-epoch rotation and producer overflow must be represented as entities: null, which means conservative resource-level invalidation, not "nothing changed". Neither key nor mutation token authorizes anything. Version-two values are deeply captured before on_commit, exactly like version-one resources: subsequent mutations cannot rewrite a queued hint.

HyperTodo's negotiated client uses a fresh 128-bit response seed followed by a 32-bit operation counter; this is not a new independently random token per operation. A matching token can silence own-change presentation, but must not skip invalidation or manufacture a successful document/layout acknowledgement. Missing metadata has no own-echo proof. Backend workers must be deployed or rolled back together: old a21 broker readers cannot consume rich events on the same namespace. Client compatibility does not imply mixed-backend compatibility.

Normal exhaustion, iterator errors, cancellation and Django's public close() path close the iterator and invoke the explicit owner callback exactly once. The public ASGI wrapper closes remaining owners in finally, including a send failure before iteration where Django may not call response close. A ContextVar carries the mutable request scope through Django's sync adapters. Normal cleanup unregisters the owner; closed scopes reject new owners, and non-HTTP scopes pass through. Same-loop close() schedules cleanup; it cannot synchronously wait on its own event loop. ASGI's other-thread close waits for the bounded cleanup task.

Cleanup permits two seconds and requires cancellation-cooperative callbacks. Errors/timeouts log only fixed codes, never exception contents. A callback that ignores cancellation, or a loop shut down before cleanup, cannot be guaranteed closed by Python; a timeout is not a successful cleanup acknowledgement. The package's own test suite exercises actual Django ASGI pre-frame error/disconnect/cancellation and never-iterated ownership regressions.