HalJsonResponseAdapter

Unwrap HAL+JSON <https://stateless.group/hal_specification.html>_ response envelopes for ApiBackend.

  1. Overview

Overview

HAL (Hypertext Application Language) is a widely-adopted convention for embedding hypermedia controls in JSON APIs. A typical HAL list response looks like this:

{
    "_links": {"self": {"href": "/articles"}},
    "_embedded": {
        "articles": [
            {
                "id": 1,
                "title": "Hello World",
                "_links": {"self": {"href": "/articles/1"}}
            }
        ]
    }
}

Without an adapter, ApiBackend has no way to know that the real records are buried inside _embedded.articles. HalJsonResponseAdapter navigates that nesting automatically and delivers a clean list of plain dicts to the model’s column-mapping layer:

[{"id": 1, "title": "Hello World"}]

Supported envelope shapes

The adapter handles a broad range of real-world payload shapes in priority order:

  1. Idiomatic HAL_embedded wrapping a list or a named sub-resource.
  2. Named list containers — top-level keys such as "items", "results", "data" (see :attr:list_container_keys).
  3. Bare list — a JSON array at the root is returned as-is.
  4. Key→record map — a dict whose non-metadata values are all dicts is treated as a named record collection; the key is injected as "key" into each record.
  5. Recursive fallback — any nested value that contains a list is unwrapped.

For single-record extraction the adapter checks :attr:record_container_keys first, then _embedded, and finally returns the entire response dict as a last resort (useful for APIs that return a bare object with no wrapper key).

Normalisation

:meth:_normalize_record removes keys listed in :attr:metadata_keys ("_links" by default) and promotes _embedded sub-resources from each record to the top level. Existing top-level keys are never overwritten by embedded content.

Note that _links is only stripped from individual record objects inside the list, not from the top-level response envelope. ApiBackend drives pagination from HTTP response headers (the Link header and X-Total-Count), so the top-level _links body field is never needed by the framework. If you subclass ApiBackend and override get_next_page_data_from_response to read _links from the JSON body, that data is still available on the raw response.json() call — the adapter does not touch it.

Full working example

Define a backend subclass that points at the upstream HAL service and attach the adapter, then wire it into a model and expose it through a list endpoint:

import clearskies


class ArticleBackend(clearskies.backends.ApiBackend):
    def __init__(self):
        super().__init__(
            base_url="https://hal.example.com",
            response_adapter=clearskies.backends.adapters.HalJsonResponseAdapter(),
        )


class Article(clearskies.Model):
    id_column_name = "id"
    backend = ArticleBackend()

    id = clearskies.columns.Integer()
    title = clearskies.columns.String()


wsgi = clearskies.contexts.WsgiRef(
    clearskies.endpoints.List(
        model_class=Article,
        readable_column_names=["id", "title"],
        sortable_column_names=["id"],
        default_sort_column_name=None,
        default_limit=10,
    ),
    classes=[Article],
)
wsgi()

Assuming https://hal.example.com/articles returns a HAL envelope, the endpoint unwraps it transparently:

$ curl 'http://localhost:8080/' | jq
{
    "status": "success",
    "error": "",
    "data": [
        {"id": 1, "title": "Hello"},
        {"id": 2, "title": "World"}
    ],
    "pagination": {"number_results": 2, "limit": 10, "next_page": {}},
    "input_errors": {}
}

Customising the adapter

All lookup keys are defined as class attributes so that subclasses can extend or replace them without overriding any logic:

from clearskies.backends.adapters import HalJsonResponseAdapter


class MyHalAdapter(HalJsonResponseAdapter):
    # Add "entries" as an additional list container key.
    list_container_keys = HalJsonResponseAdapter.list_container_keys + ("entries",)

    # Also strip "_meta" from each normalised record.
    metadata_keys = HalJsonResponseAdapter.metadata_keys + ("_meta",)

Binding the adapter via DI

You can register the adapter in the dependency injection container instead of setting it on the backend instance:

context = clearskies.contexts.WsgiRef(
    clearskies.endpoints.List(model_class=Article, ...),
    bindings={"response_adapter": HalJsonResponseAdapter()},
)

An adapter set directly on the backend always takes precedence over one registered via DI.