REST binding#

Carrying values over HTTP.

The REST semantic connector: the connector seam reaches a peer middleware.

A parameter binds a REST connector when two things hold, neither of them a marker on the parameter itself. First, the parameter is interface-accessible at all — a subproperty of inf:isInterfaceAccessibleParameter, the generic root, with no more specific protocol marker (a parameter that also carries inf:hasMQTTTopic etc. resolves to that protocol’s binding instead; wiring.py’s _descriptor_for already picks the most specific registered match). Second, the parameter’s resource carries a live svc:address on its Service, one hop out through svc:isServiceOf. That address is not a marker on the parameter — it is looked up once per resource in wiring.py’s _recognise and folded into every recognised binding’s metadata. This binding reads it out exactly like MQTT reads inf:hasMQTTBrokerIP, through binding.get.

The route is structural, so nothing else is needed. Address plus the recursive path derived from the datamodel tree is a complete binding — no REST-specific ontology term is minted. build_parameter_path does that derivation. It moved here from demo/transferunits/controller.py rather than being rewritten: the two callers — this binding, reaching outward at recognition time, and the Controller, reaching outward from a fetched JSON tree — need the identical algorithm, and a domain-level module is the wrong place to own something the middleware itself defines.

Payload shape. The payload is a list of parameter dicts, not a bare scalar, and a PUT must send back exactly what a GET returned. Unlike MQTT, which bridges a bare device scalar and the one-element list the framework holds, a peer’s own generated parameter route already serves and accepts that exact list — there is no scalar to rewrap. So RESTParameterFormatter is a plain type adapter between the wire JSON and node_model_type instances, nothing more (contrast mqtt_binding.MQTTParameterFormatter, which reassembles static facets a bare scalar would otherwise blank).

REST has no push, so northbound sync polls. That leaves three questions:

  • Batched or per-parameter? One GET per parameter, one RESTParameterConnector per registration — the same granularity MQTT already uses (one connector per topic). A shared batch-fetch-and-fan-out would need state shared across bindings that recognition does not otherwise couple, for a cost (extra GETs on a resource with few parameters) that no evidence yet justifies.

  • What interval? DEFAULT_POLL_INTERVAL_SECONDS below, conservative and not measured. The demo’s measured control lap does not include it: that lap is a unit’s own MQTT round trip, and this poll is a controller reading a peer over REST. DEFAULT_TICK_SECONDS in the demo’s control_station.py records that measurement.

  • Configurable? Yes, per connector instance (poll_interval=), which RESTBinding.build could source from a future inf: term if one ever proves necessary. None is minted here; the route needs no new term, and a cadence is not addressing.

Lazy import, same rule as MQTT: importing this module, and recognising and building a RESTBinding, must not require a working network stack, so that an inspecting instance keeps receiving the projection on a host that cannot reach anything.

That rule does not hold on this module’s import path, and the guard below is unreachable. Importing this module pulls in transitional_sync_middleware.connect.connectors.aas_client_connector, which does a bare import httpx at module level. httpx is a declared dependency of both this library and transitional_sync_middleware, so a working install always has it; were it absent, the import would die several frames above the try below. The # pragma: no cover on it is accurate rather than lazy. The MQTT half is genuinely different: aiomqtt really is an optional extra there, and mqtt_binding really does degrade to MqttClientConnector = None.

The guard stays. It is correct in isolation, it is what makes the rule true again the moment the eager import upstream goes away, and its error message is reachable and asserted (tests/test_optional_transport_stacks.py). What is not claimed here any more is that absence has ever been survivable on this path.

kapps_semantic_middleware.connectors.rest_binding.DEFAULT_POLL_INTERVAL_SECONDS = 2.0#

Default cadence of the northbound read leg’s poll loop. See the module docstring’s “REST has no push” section for why this value is provisional rather than measured.

kapps_semantic_middleware.connectors.rest_binding.build_parameter_path(root_class_local_name: str, root_iri: IRI, steps: Sequence[Tuple[str, str]], terminal_field: str) → str[source]#

Build the structural REST path for a parameter from known (field, child_id) hops.

Mirrors rest_router.py’s _accumulate_routes path shape exactly: /{Model}/{lined_root}/{field}/{lined_child}/.../{field}. Field names stay literal — the caller (recognition, here; a fetched JSON tree, for Controller) already carries them in the mangled form the served route uses. Only individual IDs are mangled here, via IRI.lined.

Moved from demo/transferunits/controller.py: the algorithm has no domain term and two callers now need it, one of them in src/.

Parameters:
  • root_class_local_name – The root resource’s class IRI, mangled (IRI(...).lined) – what kapps_ogm names the materialized pydantic class, and so the {Model} segment the served route actually mounts under. Not the bare fragment: see ParameterBinding.root_class_local_name’s docstring.

  • root_iri – The root resource’s own IRI.

  • steps – Sequence of (field_name, child_id) hops from the root to the parameter’s owner.

  • terminal_field – The final field name (the parameter itself).

Returns:

The structural URL path.

class kapps_semantic_middleware.connectors.rest_binding.RESTParameterFormatter(model_type: Type[Any], *, parameter_label: str = '', url: str = '')[source]#

Bases: object

Adapts between a peer’s parameter-route JSON body and the persistence value.

Unlike a bare MQTT scalar, the REST payload already carries the whole parameter node – value, unit, access mode, whatever the range declares – because the peer’s own GET/PUT routes serve and accept exactly that shape. There is no scalar to rewrap, so this is a type adapter between a JSON list of dicts and model_type instances, nothing more.

deserialize(data: Any) → List[Any][source]#

A peer’s GET body (or a poll’s reading) to the persistence value.

serialize(data: Any) → List[Dict[str, Any]][source]#

The persistence value to the JSON body of an outbound PUT.

Echo semantics: this is the same shape deserialize accepted, so a read-modify-write round trips through this pair with no reshaping in between.

class kapps_semantic_middleware.connectors.rest_binding.RESTParameterConnector(base_url: str, path: str, *, poll_interval: float = 2.0, poll: bool = True, timeout: float = 30.0)[source]#

Bases: object

Reaches one generated parameter route on a peer middleware over HTTP.

connect/disconnect are no-ops. HTTP is stateless here, one httpx.AsyncClient per call, the same style Controller and transitional_sync_middleware’s own HttpRequestConnector already use – there is no persistent connection worth pooling across the polling interval this runs at.

poll gates whether receive() actually polls. The write leg of a bidirectional parameter gets poll=False: its sync_direction is FROM_PERSISTENCE, so the framework’s SyncedConnector.receive() would never act on anything it yielded anyway (see synced_connector.py), and polling the same URL a second time from the write leg would only double the request rate for no effect.

async provide() → Any[source]#

GET the parameter route. Returns the parsed JSON list of parameter dicts.

async consume(body: Any) → None[source]#

PUT body to the parameter route. body already has the list shape it wants.

async receive() → AsyncGenerator[Any, None][source]#

Poll the parameter route, yielding a reading each time it changes.

REST has no push (see the module docstring). This is the polled substitute for MQTT’s subscription queue, and what the framework’s background sync task (run_receive in transitional_sync_middleware’s middleware.py) actually drives northbound sync from. A connector with poll=False never yields, so the framework spawns and immediately retires its receive task with no network traffic.

class kapps_semantic_middleware.connectors.rest_binding.RESTBinding[source]#

Bases: object

Binds a live, generically interface-accessible parameter to a REST connector.

Registers at the interface root, not a protocol-specific subproperty – REST has no parameter-local marker (see the module docstring). wiring.py’s _descriptor_for already resolves the most specific registered match, and a protocol marker such as inf:isInterfaceAccessibleMQTTParameter is itself a subproperty of this root, so a parameter that also declares a specific protocol keeps resolving to that protocol’s binding. This is the fallback for everything else – any interface-accessible parameter whose resource happens to be live.

connector_cls#

alias of RESTParameterConnector

connection_metadata: ClassVar[Tuple[IRI, ...]] = ()#

Empty on purpose. This binding reads nothing declared on the parameter – its evidence is the resource’s Service, one hop out (see the module docstring). The projection’s cross-check compares this against what the ontology declares between the parameter property and the interface root, which is likewise nothing for a parameter with no protocol-specific marker, so the two agree.

static build(binding: ParameterBinding, direction: SyncDirection, *, ensure_transport: Callable[[str, int], None] | None = None) → Iterable[Registration][source]#

One read registration when the resource is live. One write registration too, when direction permits it.

direction has already been reduced to the most restrictive of the parameter’s inf:accessMode and the instance’s connector wiring, so this only honours it.

ensure_transport is unused. REST reaches a peer middleware, not a broker this deployment needs to bring up – there is nothing here to ensure.