OpenAPI adapter details

Reference detail for httk.serve.http.openapi. Start with Serving an OpenAPI contract for the overview and a minimal end-to-end example.

The supported subset

The adapter implements a small, offline subset of OpenAPI 3.1: GET and POST paths, local references for path and operation pieces, required JSON request bodies whose schemas are external $ref values, exact numeric responses, JSON response media types, and bodyless responses. It is not a general OpenAPI implementation.

Path, query, and header parameters are supported only as simple strings, with an optional string enum; required parameters are enforced. Path declarations must exactly match the variables in their path template. Formats, defaults, coercion, and other parameter constraints are intentionally rejected.

The protocol or prototype owns its OpenAPI document and JSON Schema documents. The adapter derives everything it can from them — routes, methods, statuses, media types, request and response validation, and how each handler’s arguments are filled — so an implementation supplies the functions that implement its API and little else. Whatever cannot be derived is checked when the application is constructed rather than when a request arrives.

Packaged contracts

A protocol that ships its contract and schemas as package data loads both through one object. OpenAPIContract.from_package parses the OpenAPI document, builds an offline OpenAPISchemaRegistry over every bundled *.json file that decodes to a mapping carrying a $schema key — other *.json files below the schema root are silently skipped — and caches the result, so repeated application construction does not re-parse or re-validate the package data.

from httk.serve.http.openapi import OpenAPIContract, create_openapi_app

CONTRACT = OpenAPIContract.from_package("prototype_protocol")

async def create_thing(body):
    return await service.create(body)

app = create_openapi_app(
    CONTRACT,
    {"create_thing": create_thing},
    request_error_handler=prototype_request_error,
)

By default the contract is read from schemas/openapi.yaml and the schemas from schemas/ below the package; both are configurable. schema_transform applies a per-document fix-up to each bundled JSON Schema document before it is registered — for correcting pinned upstream defects — and is never applied to the OpenAPI document itself.

contract.document() returns an independent deep copy of the parsed OpenAPI document, contract.operations the parsed operations, contract.operation(id) one of them by operationId, and contract.schemas the offline registry.

A caller that assembles its document and schemas some other way can still pass a plain mapping together with an explicit schemas= registry. Supplying both a contract and schemas=, or a mapping without one, is a contract error.

Responses derived from the contract

Each operation exposes the status and body contracts the document declares:

  • operation.success_status — the single declared 2xx status, or None when the operation declares zero or several. A contract declaring more than one 2xx status is still accepted; only the derivation is withheld.

  • operation.success_contracts — the (media type, schema id) pairs declared for that status.

  • operation.response_contracts(status) — the same for any one status.

A handler therefore does not restate the status the document already declares. It may simply return a value:

  • None — the declared bodyless success response;

  • a mapping or list — the declared success status with that body;

  • an explicit OpenAPIResponse — when the handler needs a non-success status, a specific media type because the status declares several, or extra headers.

Response values are schema validated before they are serialized.

These accessors also let an implementation be tested against its own contract, so that facts written in Python — which error document an operation produces, which media types it can return — cannot silently drift from the document that declares them. httk.serve.dsp does this in tests/test_dsp_contract_agreement.py.

Binding handler parameters

Handler parameters are filled by name, never by position, so reordering the parameters: array in the contract can never silently swap two arguments:

Handler parameter

Bound from

the normalized name of a declared path, query or header parameter

that value

body

the validated request body

a parameter annotated OpenAPIRequest

the whole request

a name the request scope supplies

that extra

Normalization turns a wire name into a Python identifier — - becomes _, camelCase and ACRONYMCase split on case boundaries, and the result is lowercased: providerPidprovider_pid, X-Request-IDx_request_id.

An optional parameter that the request omits is not passed at all, so the handler’s own default applies. Where a wire name cannot or should not drive the Python name, operation() declares an alias:

{
    "dataset_request": operation(Provider.dataset, aliases={"id": "dataset_id"}),
    "create_thing": operation(Provider.create, aliases={"body": "message"}),
}

Everything is checked once, when the application is constructed: every required declared parameter must reach a handler parameter, every handler parameter without a default must be satisfied, two declared parameters may not normalize onto the same handler parameter, and *args, **kwargs and positional-only parameters are rejected because they cannot be bound by name.

Passing implementation= resolves entries that name unbound methods against that object with getattr, so subclass overrides are honoured.

Per-request scope

request_scope supplies values a handler cannot get from the contract, and contributes response metadata. It is an async context manager returning an OperationContext, entered around the handler call:

from functools import partial

@asynccontextmanager
async def scope(request):
    session = open_session()
    context = OperationContext(extras={"session": session})
    yield context
    # Defer work until after a clean response is sent. after_response is a
    # zero-argument coroutine callable; bind arguments with functools.partial.
    context.after_response = partial(flush, session)

Names a handler wants are declared per operation with operation(..., extras=(...)) and listed in scope_names=; both are verified at construction, so a renamed extra fails loudly instead of silently arriving as a default.

The context’s media_type, headers and after_response are folded into the response only when the handler returns normally — an adapted error response never inherits them. Statements after the yield are skipped when the handler raises, so deferred work is not released after a failed request. When both a handler-returned OpenAPIResponse and the scope set after_response, the handler’s value wins.

after_response replaces framework-specific background-task objects: the adapter wraps the coroutine callable and runs it once, after the response body has been sent. A handler may also set after_response directly on the OpenAPIResponse it returns.

Errors

Use exception_handlers only for deliberate protocol exception types, such as a protocol’s declared error object. Unexpected handler exceptions are left to the underlying server; the adapter does not mask programmer errors. path_converters provides a transparent mapping from OpenAPI parameter name to route converter, for example {"id": "path"}.