Serving entry providers

Note

If the records already live in an EntryStore, do not build a provider that enumerates and copies them. Pass the store directly to create_asgi_app; see Serving directly from an entry store. The provider path on this page is intentionally the in-memory path for generated or compatibility datasets.

httk-serve is a generic implementation of the OPTIMADE protocol: it carries no knowledge of what it serves. Everything served — entry types, their properties, and the records — is supplied through the neutral httk.core.EntryProvider contract. This page shows how to write a provider, serve it, and query it. For the internals, see How it works.

Write a provider

A provider answers three questions: what entry types do you serve (each described by a first-class httk.core.EntryTypeDefinition), which record key holds each property (the property-key map must cover at least id and type, and every served name must be described by the definition), and what are the records (plain JSON-able mappings). Property definitions are generated from a compact description with PropertyDefinition.from_simple (or loaded from the vendored standards). A minimal provider serving a custom widgets entry type:

from collections.abc import Iterable, Mapping
from typing import Any

from httk.core import EntryProvider, EntryTypeDefinition, PropertyDefinition


class WidgetProvider(EntryProvider):
    """A minimal provider serving a custom ``widgets`` entry type."""

    def __init__(self, widgets: list[dict[str, Any]]) -> None:
        self._widgets = widgets

    def entry_types(self) -> Mapping[str, EntryTypeDefinition]:
        return {
            "widgets": EntryTypeDefinition(
                "widgets",
                "A widgets entry.",
                {
                    "id": PropertyDefinition.from_simple("id", description="The widget id.", required_response=True),
                    "type": PropertyDefinition.from_simple("type", description="The entry type.", required_response=True),
                    "cogs": PropertyDefinition.from_simple("cogs", description="Number of cogs.", fulltype="integer"),
                    "tags": PropertyDefinition.from_simple("tags", description="Tag labels.", fulltype="list of string"),
                },
            )
        }

    def property_keys(self, entry_type: str) -> Mapping[str, str]:
        return {"id": "__id", "type": "type", "cogs": "cogs", "tags": "tags"}

    def records(self, entry_type: str) -> Iterable[Mapping[str, Any]]:
        return self._widgets


provider = WidgetProvider(
    [
        {"__id": "w-1", "type": "widgets", "cogs": 3, "tags": ["red", "small"]},
        {"__id": "w-2", "type": "widgets", "cogs": 5, "tags": ["blue"]},
    ]
)

Each property’s type drives which filter operations the engine offers for it (comparisons for numbers, string matching for strings, HAS membership for lists) — no handler code is needed. Custom (database-specific) properties must carry a recognized prefix (_httk_ is pre-registered; others via httk.core.register_definition_prefix) and be merged into a standard definition with EntryTypeDefinition.extended({...}).

Serve it

adapter_from_providers turns one or more providers into a fully wired in-memory backend adapter; serve runs a development server (or use create_asgi_app with any ASGI server):

from httk.serve.optimade import adapter_from_providers, serve

serve(adapter_from_providers([provider]), port=8080)

The API is then live, e.g.:

curl 'http://localhost:8080/v1/widgets?filter=cogs=5'
{
 "data": [
  {"attributes": {"cogs": 5, "tags": ["blue"]}, "id": "w-2", "type": "widgets"}
 ],
 "links": {"next": null},
 "meta": {"api_version": "1.3.0", "data_available": 2, "data_returned": 1, "...": "..."}
}

Filters compose over the described properties: filter=cogs>3 AND tags HAS "blue", filter=id="w-1", and so on; /v1/info and /v1/info/widgets are generated from the provider’s descriptions. A runnable version of this example is in examples/optimade/provider_server/, and examples/optimade/demo_server/ shows the lower-level wiring (custom EntrySources, handler tables, and an OptimadeConfig with provider links) that adapter_from_providers automates.

Mounted deployments and browser access

When create_asgi_app() has no explicit baseurl, generated resource and pagination links use the ASGI mount path. For example, mounting the app at /optimade makes a request to /optimade/v1/structures generate links below /optimade/v1/; an unversioned request remains below /optimade/. If the public URL differs from ASGI’s incoming scheme or host, pass baseurl as the unversioned public API base instead. It remains authoritative, while a versioned request still adds its single /v1/ segment to generated links.

Cross-origin browser reads are disabled by default. To opt in, configure an exact HTTP(S) origin allowlist; wildcard origins and credentials are not supported:

from httk.serve.optimade import OptimadeConfig, create_asgi_app

app = create_asgi_app(
    adapter,
    OptimadeConfig(cors_origins=("https://table.example",)),
)

Allowed origins receive CORS responses for GET, HEAD, and preflight OPTIONS requests. Origins must not include paths (apart from a trailing slash), credentials, queries, or fragments. Hostnames must be ASCII; configure an internationalized domain name in browser-compatible punycode form so it matches the browser’s Origin header exactly.

The largest page_limit a client may request is OptimadeConfig.page_limit_max (default 50). Raise it to serve larger pages, e.g. OptimadeConfig(page_limit_max=500). Per the OPTIMADE spec a request for a page_limit above this maximum is rejected with HTTP 403 Forbidden rather than silently clamped; the default page size (when no page_limit is given) is unaffected.

Query programmatically

For tests or in-process use, skip HTTP and drive the engine directly:

from httk.serve.optimade import adapter_from_providers
from httk.serve.optimade.backend import execute_query
from httk.serve.optimade.filter import parse_optimade_filter

adapter = adapter_from_providers([provider])
results = execute_query(
    adapter, ["widgets"], ["id", "cogs"], [], 100, 0,
    parse_optimade_filter('tags HAS "red"'),
)
print([r.values["id"] for r in results])  # ['w-1']

Relationships, include, and relationship filtering

A provider can declare related entries by overriding the optional EntryProvider.relationships(entry_type) hook, which maps each entry id to a flat tuple of httk.core.RelatedEntry objects (each naming the related entry type and id, optionally with a per-identifier description, a v1.3 role, a label served as meta._httk_label, and a wire-form relationship semantic key). adapter_from_providers wires that into the served OPTIMADE relationships block automatically (grouped by relationship or entry_type — a semantic key when declared, otherwise the target entry type — with each identifier’s type being the target type, and metadata rendered as the JSON:API meta object), so an include=<type> request embeds the related resources (resolved from whichever provider serves that related type). A provider can also override the optional EntryProvider.reverse_relationships() hook (which takes no argument): it returns a nested mapping — target entry type → target id → RelatedEntry tuple — of the derived reverse of edges the provider owns, and adapter_from_providers append-merges those onto the targets’ forward blocks so both directions are served.

Filtering is auto-wired too: references.id HAS "ref-1" matches over the declared ids, and depth-1 relationship-property filters such as references.title CONTAINS "study" are resolved by filtering the related entry type’s own properties (each dotted filter node independently). The same relationships are also reachable through the httk-specific _httk_relationships.<key>.id HAS ... extension, where <key> is either a typed alias (_httk_relationships.references.id == bare references.id) or one of the semantic provenance keys, which have no standard spelling. On the in-memory provider route the filterable key set is observation-derived — only keys present in the served data are filterable (an empty dataset 400s). A provider that does not override the hook is unaffected — no relationships block is emitted.

from collections.abc import Iterable, Mapping
from typing import Any

from httk.core import EntryProvider, EntryTypeDefinition, PropertyDefinition, RelatedEntry, standard_entry_type
from httk.serve.optimade import adapter_from_providers
from httk.serve.optimade.backend import execute_query
from httk.serve.optimade.filter import parse_optimade_filter


class LinkedProvider(EntryProvider):
    def entry_types(self) -> Mapping[str, EntryTypeDefinition]:
        structures = EntryTypeDefinition(
            "structures",
            "Structures.",
            {
                "id": PropertyDefinition.from_simple("id", description="id", required_response=True),
                "type": PropertyDefinition.from_simple("type", description="type", required_response=True),
            },
        )
        return {"structures": structures, "references": standard_entry_type("references")}

    def property_keys(self, entry_type: str) -> Mapping[str, str]:
        if entry_type == "structures":
            return {"id": "__id", "type": "type"}
        return {"id": "__id", "type": "type", "title": "title"}

    def records(self, entry_type: str) -> Iterable[Mapping[str, Any]]:
        if entry_type == "structures":
            return [{"__id": "s-1", "type": "structures"}]
        return [{"__id": "ref-1", "type": "references", "title": "A study"}]

    def relationships(self, entry_type: str) -> Mapping[str, tuple[RelatedEntry, ...]]:
        if entry_type == "structures":
            return {"s-1": (RelatedEntry("references", "ref-1", description="Cited for this structure"),)}
        return {}


adapter = adapter_from_providers([LinkedProvider()])
(row,) = list(execute_query(adapter, ["structures"], ["id", "type"], [], 100, 0))
assert row.relationships == {"references": [{"id": "ref-1", "description": "Cited for this structure"}]}

# Relationship filtering works without any handler wiring:
(row,) = list(
    execute_query(
        adapter, ["structures"], ["id"], [], 100, 0,
        parse_optimade_filter('references.title CONTAINS "study"'),
    )
)
assert row.values["id"] == "s-1"

A runnable server showcasing declared relationships (serving, include, and both kinds of relationship filtering) is in examples/optimade/provider_server/.

Discover registered providers

Provider packages can self-register a factory from a registration package under the reserved entries tier (httk.registry.entries.<module>) via httk.core.register_entry_provider. For example, httk-store registers in-memory providers for the standard references/files/calculations entry types (as store-references/store-files/store-calculations), and httk-atomistic registers atomistic-structures. providers_from_registry resolves everything registered in the current environment; since providers need data, you instantiate them:

from httk.serve.optimade import adapter_from_providers, providers_from_registry

factories = providers_from_registry()
provider = factories["atomistic-structures"](my_structures)  # requires httk-atomistic
adapter = adapter_from_providers([provider])

Serving crystal structures (via httk-atomistic)

The materials mapping lives in httk-atomistic, not here: its StructureEntryProvider maps UnitcellStructure objects to an OPTIMADE structures entry type (species, species_at_sites, lattice_vectors, cartesian_site_positions, nsites, elements, nelements, structure_features). With httk-atomistic installed:

from httk.atomistic import UnitcellStructure, StructureEntryProvider
from httk.serve.optimade import adapter_from_providers, serve

nacl = UnitcellStructure(
    cell=(5.64, 5.64, 5.64, 90.0, 90.0, 90.0),
    sites=[[0.0, 0.0, 0.0], [0.5, 0.5, 0.5]],
    species=[
        {"name": "Na", "chemical_symbols": ["Na"], "concentration": [1.0]},
        {"name": "Cl", "chemical_symbols": ["Cl"], "concentration": [1.0]},
    ],
    species_at_sites=["Na", "Cl"],
)

serve(adapter_from_providers([StructureEntryProvider({"nacl": nacl})]))
curl 'http://localhost:8080/v1/structures?filter=elements HAS "Na"'

Neither package imports the other — the contract in httk-core is the only coupling, which is why the two install and evolve independently.