ocpi-kit
A Rust toolkit for OCPI (Open Charge Point Interface), the protocol that carries EV roaming traffic between Charge Point Operators (CPO), e-Mobility Service Providers (eMSP) and roaming hubs.
It is not only a set of types. ocpi-kit gives you the wire models for OCPI 2.3.0, 2.2.1 and
2.1.1, the transport envelope, an async client, an axum server, the pieces a roaming hub
needs, an auditable tariff engine, and a CLI — behind cargo features, in one crate.
[]
= "0.1"
The four properties that decide quality
Money is never a float. Every number in every object is an exact decimal
(rust_decimal::Decimal behind types::Number). No public field of any OCPI object in this crate
is an f32 or f64, the pricing engine has none either, and cargo run -p xtask -- no-floats
enforces that in CI. Every other Rust OCPI type set models prices as f64; a binary float cannot
represent 0.10, and cents decide real disputes.
Nothing a peer sent is thrown away. Undocumented JSON fields land in a types::Extensions map
and are written back verbatim. An enum value this crate has never heard of keeps its text in a
Custom(String) variant. A hub built on ocpi-kit forwards a vendor extension it does not
understand without damaging it — which is what OCPI 2.3.0's extensibility chapter asks for, and the
single most common way real hubs lose data.
Parsing and conformance are separate questions. A peer that overruns a string(45) cannot make
a whole page of Locations undecodable. The value arrives; Validate::validate reports it with an
RFC 6901 JSON Pointer:
use Validate;
use Location;
let location: Location = from_str?; // permissive
for v in location.validate.unwrap_err.iter
The rule throughout is: parse permissively, validate explicitly, construct strictly.
One model, whatever the peer speaks. Your handlers, your client calls and your hub are written
against OCPI 2.3.0. The wire speaks whatever the other party runs — which, in 2026, is usually 2.2.1.
ocpi-kit translates at the edge and tells you what the crossing cost:
// The peer is on 2.2.1. What comes back is a v2_3_0::tariffs::Tariff.
let mut tariffs = peer.tariffs.list?;
while let Some = tariffs.next.await?
A 2.2.1 Tariff has no tax_included, which 2.3.0 made required — so a library that models both
versions and translates neither cannot decode most of the market. Every other OCPI library, in every
language, leaves that translation to you.
What is in the box
| Layer | Feature | What it gives you |
|---|---|---|
types |
(always) | CiString, DateTime, Number, Url, Extensions, RFC 6901 validation |
v2_3_0 |
v2_3_0 |
the OCPI 2.3.0 wire model — 59 objects, all modules |
v2_2_1 |
v2_2_1 |
the OCPI 2.2.1 wire model — 53 objects, as a delta from 2.3.0 |
v2_1_1 |
v2_1_1 |
the OCPI 2.1.1 wire model — 33 objects (legacy peers) |
bookings / invoice-reconciliation |
same | the 2.3.0 bookings and payments release branches |
convert |
convert |
Upgrade/Downgrade between versions, with loss accounting — and the JSON-level bridge the client, server and hub use |
transport |
transport |
envelope, status codes, headers, credentials tokens, pagination, routing, PATCH |
client |
client |
async client over reqwest, registration handshake, paginated crawls, a typed client per module — canonical objects whatever the peer runs |
server |
server |
axum router driven by one trait per module and interface; publish 2.2.1 and 2.3.0 from one set of handlers |
hub |
hub |
routing table, broadcast push, open routing, GET All, version bridging |
tariffs |
tariffs |
auditable pricing engine over CDRs and Sessions |
testkit |
testkit |
validated samples, in-memory stores with spec-accurate pagination, and MockPeer — a complete, conformant OCPI party to point a partner at |
| conformance runner | client |
drive a live peer through the spec's rules, read-only |
schema |
schema |
JsonSchema for every wire type |
ocpi CLI |
cli |
validate, versions, pull, price, convert, conformance, serve-mock, schema |
Default features are v2_3_0, v2_2_1 and transport. full turns on everything except the CLI.
Client
use ;
use ;
use ;
use ;
let client = new?;
let me = new?;
// The registration handshake as a typestate: Registration → Discovered → Selected → Peer.
// You cannot call a module endpoint before discovery, or reuse TOKEN_A after registering.
let peer = new
.discover.await?
.select_best.await?;
// Refuse to register with a peer that does not implement what we need — before POSTing.
peer.require?;
let peer = peer.register.await?;
// Then pull, following every `Link: rel="next"` header.
let mut locations = peer.locations.list?;
while let Some = locations.next.await?
The client refuses to call a URL it should not: Credentials.url, Endpoint.url and every
response_url are attacker-influenced, so a UrlPolicy rejects plain HTTP, loopback and private
addresses by default — with an explicit note on what URL inspection alone cannot
do, because a policy that oversells
itself is worse than none. It validates what it sends (ClientConfig::validate_outgoing, on by
default). It only retries GET, because the spec says messages must not be queued and
replayed. And it never logs a token.
Server
use ;
use ;
let app = new
.credentials
.locations_sender
.build;
serve.await?;
One trait per module and interface — Locations, Sessions, CDRs, Tariffs, Tokens, Commands, Charging Profiles, Payments, Hub Client Info and Credentials — and the router handles the rest:
- The status-code rules. Only five situations get an HTTP error status; everything that reached
the OCPI layer is
200 OKwith a four-digit code in the body. CREDENTIALS_TOKEN_Ascoping. A bootstrap token used on any module other thancredentialsandversionsgets a 401.- Ownership of client-owned objects. A platform writing under a
country_code/party_idthat is not one of its own roles gets a 404, and your handler is never called. X-Request-ID/X-Correlation-ID, echoed and generated./versionsand version details generated from exactly what was mounted, so discovery cannot disagree with reality.- The PATCH rule. A patch without
last_updatednever reaches a handler. - The version it publishes. A router is built for one OCPI version, because that version is part
of its base URL —
/ocpi/cpo/2.3.0and/ocpi/cpo/2.2.1are different endpoints with independently discovered URLs. The handlers behind them are the same: they are written againstv2_3_0, and a router published as 2.2.1 upgrades each request body and downgrades each response at the edge. Serving both versions is mounting the same handlers twice, not writing the modules twice. A version this build cannot write is refused at start-up rather than answered wrongly. - The callback URLs OCPI leaves to you. The asynchronous halves of Commands and Charging
Profiles are posted to a
response_urlwhose shape the spec explicitly does not define. For Charging Profiles that is load-bearing rather than merely open:ChargingProfileResultandClearProfileResultare the same JSON object, so nothing but the URL can tell a rejectedPUTfrom a rejectedDELETE. The router mounts one path per result kind andserver::CallbackUrlsbuilds the matching URLs, so the two cannot drift apart.
Hub
The hub feature recognises all four routing arrangements from the headers and method alone
(Direct, Broadcast Push, Open Routing Request, GET All via hub) — and refuses the two
combinations that are not arrangements at all, rather than picking the nearest one, because a
GET addressed to the hub silently becoming a Broadcast Push is the thing the spec forbids in the
same sentence. It keeps the rules a hub must not break (a new X-Request-ID with the same
X-Correlation-ID; last_updated never touched; GET never broadcast; configuration modules
never routed).
And it translates between versions in flight. The routing table knows what each connected
platform speaks; Forwardable carries the version its body arrived in. The forwarder converts the
request on the way out, converts the response on the way back, and appends what the crossing cost to
the status_message, so the requesting party can see it:
let relayed = forwarder.relay.await; // 2.3.0 eMSP ⇄ 2.2.1 CPO
let response = relayed.outcome?;
// response.data is in the requester's version.
// response.status_message: "version bridged with 1 loss(es): /help_phone: OCPI 2.2.1 has no …"
A crossing with no conversions — anything involving 2.1.1 — is refused rather than relayed:
handing a 2.1.1 object to a 2.3.0 party produces a document the receiver misreads rather than
rejects. Forwarder::on_unbridgeable(Unbridgeable::RelayVerbatim) is there for a hub that is
deliberately a pipe.
Tariffs
OCPI is the only protocol that carries both the tariff and the metering data, so a session's cost is computable from what crosses the wire. That is how an eMSP checks a CPO's invoice.
let breakdown = new.price?;
assert_eq!;
The answer is auditable: CostBreakdown does not just say 12.28, it says which quantity was
billed for each dimension, what step_size did to it, which Tariff Element and which Price
Component priced it, and why that element was selected. The arithmetic is exact. The parts the
specification deliberately leaves open — rounding, and step_size itself, which OCPI 3.0 removes —
are settings on PricingPolicy, not assumptions baked into the code.
It also audits the CDR it is pricing. A Charging Period is a total, not a curve, so a period that outlasts the price component pricing it cannot be apportioned after the fact — and the specification puts the obligation on the CPO instead: "A CPO SHALL at least start (and add) a ChargingPeriod every moment/event that has relevance for the total costs of a CDR." Every implementation assumes that and prices the period at the rate that applied when it began. This one does too, and then says so:
$ ocpi price cdr.json --tariff tariff.json
ENERGY 10 billed ( 10 measured) = 2.0 excl. VAT, 0 VAT
TOTAL 2.0 excl. VAT, 2.0 incl. VAT
[period_spans_price_change] the ENERGY Charging Period starting here outlasts the Price Component
that prices it: element 1 applies at the start and element 0 by the time the period ends. …
the CDR's own total agrees
error: the CDR did not reconcile; see the breakdown above
The CDR's own total agrees — and the CDR is still malformed. Exit code 1, so the invoice check is a pipeline step rather than something somebody reads. Notes carry a machine-readable code, so a reconciliation run can count them rather than grep them.
And the breakdown holds together as a document: the tax lines always sum to exactly
total_incl - total_excl, including when a min_price or max_price moved the total, which is a
property test rather than a hope.
Ten of the specification's own worked examples are tests, and two more are snapshots — the
step_size example rendered in full, next to the same session under the OCPI 3.0 policy that has
no step_size, which is the clearest statement of what block billing costs a driver.
Conformance
Drive a live peer through the specification's rules and report where it disagrees.
$ ocpi conformance https://cpo.example.com/ocpi/versions
[+] versions.common the peer offers a version this build speaks
2.3.0, 2.2.1
[x] headers.request_id X-Request-ID is echoed
absent from the response
spec: 2.3.0 §transport_and_format_request_id
[!] module.xlimit locations sends an X-Limit header
absent, so a client cannot tell whether its limit was reduced
21 passed, 1 failed, 1 warnings, 4 skipped
It checks discovery, the endpoint list, header echoing, clock skew, authentication, and one page
from every Sender interface the peer offers — pagination headers, limits, Link: rel="next", and
whether the objects conform. Every check names the specification anchor behind it, so a failing
line pastes straight into a ticket.
Two checks are the reason to run it at all: whether the peer actually applies offset, and whether
it applies date_from. Neither is visible in any single response, and both are expensive — one
makes a crawl loop, the other turns every incremental pull into a full one.
It is read-only — GETs plus two deliberately-unauthenticated requests, never a write — so
it is safe against a production partner. Non-zero exit on failure makes it a CI gate, and
ocpi_kit::client::Conformance is the same thing as a library type.
CLI
$ cargo install ocpi-kit --features cli
$ ocpi validate --as location location.json # every length limit and cross-field rule
$ ocpi versions https://cpo.example.com/ocpi/versions --token "$OCPI_TOKEN"
$ ocpi pull locations https://cpo.example.com/ocpi/versions # $OCPI_TOKEN, every page
$ ocpi pull payment-terminals https://ptp.example.com/ocpi/versions
$ ocpi price cdr.json --tariff tariff.json # what it should have cost, and why
$ ocpi convert --as cdr --from 2.2.1 --to 2.3.0 cdr.json
$ ocpi conformance https://cpo.example.com/ocpi/versions # read-only, exits non-zero on failure
$ ocpi serve-mock # a conformant peer on :8080, for a partner to hit
$ ocpi schema location --version 2.3.0
serve-mock gives a partner something to point a half-written client at: an endpoint that
paginates, applies date_from, refuses a write under the wrong party, answers 2004 for an unknown
token and rejects a PATCH with no last_updated. It runs testkit::MockPeer, which the test suite
holds to this crate's own conformance runner. --version 2.2.1 serves 2.2.1 bytes from the same
handlers.
Spec traceability
Every public item carries a Spec: <version> §<anchor> line naming the AsciiDoc anchor in the OCPI
source it implements, so a reviewer — or a partner's compliance team — can go from a Rust type
straight to the sentence that defines it.
That is checkable, not decorative. Repository automation compares the crate against the specification:
$ cargo run -p xtask -- spec-coverage --check # every object's fields vs the spec's property tables
$ cargo run -p xtask -- enum-coverage --check # every enum's values vs the spec's value tables
$ cargo run -p xtask -- sync-fixtures # re-import the spec's own JSON examples
$ cargo run -p xtask -- no-floats # the no-f64 guarantee
$ cargo run -p xtask -- dead-config # every setting does something
dead-config fails the build if a public field of Quirks, ClientConfig, ServerConfig,
PricingPolicy or UrlPolicy is only ever assigned and never read, or shares a name with a field
on another of them. A configuration field that does nothing is worse than a missing feature:
somebody sets it, believes the problem is handled, and ships.
spec-coverage checks five releases — 2.3.0, 2.2.1, 2.1.1 and the bookings and payments
branches, 275 object comparisons — and all of them match the property tables exactly.
enum-coverage checks the same five releases' 159 enums, and all of them match their value tables
exactly.
How it is verified
- Field census against the specification's own property tables, as above.
- Enum census against the specification's own value tables. A field census says nothing about
what may go in the field: a missing enum value stops a conformant peer's object decoding on a
closed enum, and on an open one it survives in
Custom(_), fails nothing, and silently never matches the variant you wrote amatcharm for. - Round-trip of every example the specification ships — all 218, across four corpora. Each needs a recorded expectation, so a newly synced example cannot pass unexamined; where the spec's own example is wrong the reason is written down and the test asserts it still fails, so an upstream fix surfaces as a failure.
- Property tests for the laws the rest of the crate relies on:
Eq/Hash/Ordagreement on case-insensitive identifiers, merge-patch idempotence, exact decimal arithmetic across the JSON boundary, conversion round-trips, well-formed JSON Pointers — and panic-freedom for every parser a peer controls the input of. There is nounsafehere, so a hostile peer's leverage is a panic; one inside a hub's forwarder kills a task holding somebody else's message. - Mock peers that misbehave. The end-to-end test proves client and server agree;
wiremockpeers prove the client survives partners that don't — an OCPI 3.0-only peer, pagination that points at itself forever, a2003inside a200, a 503 that must not be retried on a write. - One set of handlers served as two versions, asserting on the JSON that actually crossed the
socket: a
Tariffthat must not carrytax_includedon a 2.2.1 router, aLocationwhosehelp_phoneis dropped at the edge and nowhere else, a client on a 2.2.1 peer, and a hub relaying between the two. A version test using the crate's own types on both sides of the wire tests nothing. - A real hub relaying to real downstream servers, asserting on the
OCPI-to-/OCPI-from-headers the downstream party actually received — not on what the forwarder meant to send. - The reference peer
ocpi serve-mockruns, held to the conformance runner and to every typed client call, with five modules mounted on both interfaces at once. - Snapshots of the artefacts a person reads — pagination headers, the whole error vocabulary as the JSON a peer is shown, a priced session in full. Round-trip tests prove a value survives; they say nothing about whether what comes out is legible.
- The conformance runner pointed at our own server, so the two keep each other honest.
516 tests across twelve targets. Clippy at pedantic with -D warnings under three feature sets,
every feature and every pair of layer features compiled, cargo deny, and benchmarks for what the
guarantees cost:
$ cargo bench --bench wire
Exact arithmetic is about 9× slower than binary floating point, and a thousand decimal additions still cost under 2% of decoding the page they arrived on. The guide has the numbers.
Documentation
- API documentation — every item, with the spec anchor it implements
- The guide — the protocol in brief, concepts, per-layer walkthroughs, interop notes, how this is verified, the spec errata and the design decisions behind the crate
Minimum supported Rust version
1.96. Raising it is a minor-version change.
Contributing
Issues and pull requests are welcome. cargo test --all-features, cargo clippy --all-targets --all-features and both xtask checks must pass; CI runs them plus a feature-combination sweep,
cargo deny and a docs build.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
OCPI is a protocol owned and maintained by the EVRoaming Foundation. This project is not affiliated with the EVRoaming Foundation.