ocpi-kit

Design decisions

Why ocpi-kit is shaped the way it is: the choices the specification leaves open, and the ones it does not make at all.

On this page

OCPI leaves a good deal to the implementer. Every choice this crate made in that space is recorded here with its reasoning. Where the specification is at fault rather than merely quiet, the entry is in Spec errata instead.

Types

OcpiString<N> counts Unicode scalar values, not bytes. The specification is silent. Scalar values are what a human means by "45 characters", and a peer that sends an accented name should not be told it is too long. Ingest is lenient; the limit is reported by validate(). See Parse, validate, construct.

Wire structs are not #[non_exhaustive]. They mirror a published, versioned document: a new field is a new OCPI version, which is a new module here. Marking them would cost every user struct-literal construction and exhaustive matching for a flexibility the domain does not have. The behavioural enums this crate may itself extend — Bridge, RoutingScenario, ViolationCode, Unbridgeableare #[non_exhaustive].

The open-enum catch-all is Custom, not Other. ImageCategory and TokenType each have a real spec value called OTHER. See Open enums.

From<&str> is lenient; new() and FromStr are strict. The builders need an infallible conversion to be usable, so the strictness guarantee lives at the wire instead (ClientConfig::validate_outgoing, on by default), where it also catches the cross-field rules no constructor could see.

f64Decimal goes through the shortest round-tripping string. Decimal::try_from(f64) is not exact; f64::to_string prints the decimal the peer actually wrote. See Numbers.

time is fully wrapped. types::DateTime exposes no time type in its public API — the conversions to and from OffsetDateTime are crate-private — so the backend could be swapped for jiff without that being a breaking change. Callers get whole seconds from unix_timestamp() and full precision through the RFC 3339 text.

A charging period is a total, not a curve, and the crate will not pretend otherwise. Cdr::period_spans() closes the interval boundaries, which are unambiguous. Re-cutting those intervals onto a finer grid is an assumption the CDR does not carry and the specification declines to make, so it stays with the caller who has to record it. Session gets no equivalent: its final period has no end while the session runs, and the whole list is replaced on every PATCH. See Reading a CDR.

SignedData.url is modelled as the string(512) the specification says, not as a Url. It is the only URL-shaped field in OCPI that is not the URL type, which is string(255) — so modelling it as one rejects a conformant link. See Spec errata.

Versions

Everything above transport speaks the canonical 2.3.0 model. The alternative — one version-polymorphic model with serde tricks — hides real semantic differences (Price, tax_included), makes lossy conversions implicit and produces worse error messages. Explicit per-version modules plus conversions are more code and strictly more correct. See Versions and conversion.

client, server and hub enable convert. They speak the canonical model, which is only defensible if they can reach a peer that does not — and most peers do not. Bridging is part of what makes those layers work, not an optional extra.

bridgeable is the single predicate for "can this build carry a document between these two versions". Whether both versions are modelled is a different question, and answering that one instead promises translations that do not exist.

A merge patch crosses a version boundary by inspection, not conversion. A patch is not an object, so it cannot be decoded and re-encoded; but one writing only fields the two versions agree about means the same thing in both. ObjectKind::divergent_fields is the list they disagree about.

v2_2_1 implements the 2.2.1-d2 errata semantics. The bugfix branch is what people deploy.

OCPI 2.1.1 is modelled and deliberately not bridged. It has no owner fields on objects, no routing headers and no Price, so carrying an object across that boundary is a decision about a particular deployment rather than a translation a library can make on its own.

Server

A router refuses at start-up to publish a version it cannot write. The handler traits speak 2.3.0; a router advertising 2.1.1 would answer partners with plausible objects in a shape that version does not define. See Server.

ServerConfig::receiver_path_prefix defaults to Some("receiver"). Locations Sender {loc}/{evse}/{conn} and Receiver {cc}/{party}/{loc} are both three segments, so no route ordering can tell them apart. Either publish the Receiver interfaces one segment deeper, or run one router per role; mounting both with no prefix panics at start-up, in either order.

A cap is enforced where it is imposed. ServerConfig::max_page_limit clamps in the Page extractor, so a handler is handed a limit it can honour literally and X-Limit describes what actually happened rather than annotating a page that was already built.

A server validates what it is about to send, warns, and sends it. Refusing a partner's GET because one Location in a page of a hundred has a 46-character name turns your data quality into an outage on their side; serving it silently is how it becomes their support ticket. It is not a setting, because a switch that only silences a log line is not one.

The Sender-side callback paths are fixed by this crate. OCPI hands the response_url shape to the implementation, and for Charging Profiles that freedom is the only thing that can distinguish two identically-shaped result bodies. See Spec errata.

Hub

A hub refuses a request whose headers and method match no routing scenario, rather than picking the nearest one. OcpiError::NotRoutable carries the specification's own advice: omit the OCPI-to- headers and make it an Open Routing Request. See Hub.

A crossing this build cannot translate is refused rather than relayed, by default. Handing a 2.1.1 object to a 2.3.0 party produces a document the receiver misreads rather than rejects. Unbridgeable::RelayVerbatim is there for a hub that is deliberately a pipe.

Tariffs

Default rounding is half away from zero, applied at the component and then summed. The specification says nothing about rounding on purpose. PricingPolicy carries the choice and CostBreakdown records what was applied, and the specification's own worked tariff examples pass with the default.

PricingPolicy separates reported precision from monetary precision. A duration in hours is a repeating decimal, so a verbatim measurement is a number JSON cannot hold. quantity_decimals is what the breakdown says was measured; money is computed from the exact quantity regardless.

A period that outlasts its price is priced, not refused. Erroring out would leave the party with no number at all for a CDR a partner has already invoiced. The rate that applied when the period began is the only defensible answer from the data, and the note is what makes it reviewable.

A PricingNote carries a code, not just a sentence. A reconciliation pipeline has to be able to ask "how many CDRs this month span a price change?", and grepping English is not a way to do that. See Tariffs.

Verification and packaging

The conformance runner is read-only and does not exercise registration. A check that mutates the peer it is checking cannot be run against production. See Conformance.

MockPeer belongs in the crate. The dozen handler traits over a store are the same dozen for everybody, and each is a chance to get pagination or the ownership rule slightly wrong and then test against that mistake. Shipping it makes conformance this crate's job, which is testable. See Testkit.

The no-float rule is enforced by xtask, not clippy, and a configuration field must be read, not merely assigned. Both are explained under Spec traceability.

The specification is not vendored. OCPI is CC BY-ND 4.0, so specs/ is a gitignored local checkout. The fixtures extracted from it are committed, so the round-trip suite runs everywhere.

MSRV is a fixed pin per minor release. A floating "latest stable minus two" lets a patch release break a consumer's build.

One crate, not a workspace. Cargo features give the same dependency isolation and keep versioning and documentation in one place. The module boundaries are drawn so a split stays mechanical if compile times ever demand it.

Types are not generated from JSON schemas. There are no official OCPI schemas, and the community ones encode f64 money and drop CiString semantics. This crate generates tests from the specification text instead of code — see Spec traceability.

Pushes are never auto-retried or queued. The specification says not to; Resync is the answer. See Client.

Improve this page on GitHub