Server
An axum router driven by one trait per OCPI module and interface, with discovery generated from what you mounted.
On this page
An OCPI server: one trait per module and interface, mounted onto an axum::Router.
let app = OcpiRouter::new(VersionNumber::V2_3_0, base_url, tokens)
.credentials(credentials_handler)
.locations_sender(locations)
.cdrs_receiver(cdrs)
.build();
axum::serve(listener, app).await?;
You implement the traits; the router does the protocol.
There is a trait — usually two, one per interface — for every module the crate models a protocol
for: Credentials, Locations, Sessions, CDRs, Tariffs, Tokens, Commands, Charging Profiles,
Payments and Hub Client Info. /versions and the version-details endpoint are generated, not
implemented.
What the router takes care of
- The status code rules. Only five situations get an HTTP error status; everything that reached
the OCPI layer is a
200 OKwith a four-digit code in the body. A handler returnsOcpiErrorand the mapping happens once, correctly. - Authentication and the
CREDENTIALS_TOKEN_Ascope. A bootstrap token used on any module other thancredentialsandversionsgets a 401, as the specification requires. - Ownership of client-owned objects. A platform writing under a
country_code/party_idthat is not one of its own roles gets a 404 — "this way blocking client access to objects that do not belong to them" — and your handler is never called. X-Request-IDandX-Correlation-ID. Echoed on every response, generated when the peer forgot them.- Version details.
/versionsand the version-details endpoint are 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. See below.
- The callback URLs the specification leaves open. See further below.
Serving 2.2.1 from 2.3.0 handlers
A router is built for one OCPI version because that version is part of its base URL:
/ocpi/cpo/2.3.0 and /ocpi/cpo/2.2.1 are different endpoints with independently discovered URLs.
The handlers are the same objects — every trait in server speaks
v2_3_0, and a 2.2.1 router upgrades each request body and downgrades
each response data at the edge:
let cpo = MyCpo::new(store); // implements LocationsSender, TariffsSender, …
let modern = OcpiRouter::new(VersionNumber::V2_3_0, base.join("2.3.0"), tokens.clone())
.locations_sender(cpo.clone())
.tariffs_sender(cpo.clone())
.build();
let legacy = OcpiRouter::new(VersionNumber::V2_2_1, base.join("2.2.1"), tokens)
.locations_sender(cpo.clone()) // the same handler
.tariffs_sender(cpo)
.build();
let app = Router::new().nest("/ocpi/cpo/2.3.0", modern).nest("/ocpi/cpo/2.2.1", legacy);
Serving both versions is mounting the same handlers twice, not writing the modules twice. A Tariff
served to a 2.2.1 partner must not carry tax_included, which 2.3.0 made required — a router
that only labelled itself 2.2.1 would be sending a document that version does not define.
The middleware is installed only when there is a translation to make, so a canonical router pays
nothing. A PATCH writing a field the versions disagree about is refused with the specification's
GET → PUT recovery rather than misapplied, and build() panics on a version this build cannot
write — today, anything but 2.3.0 or 2.2.1.
Asynchronous results, and the URL that has to carry the distinction
Commands and Charging Profiles answer twice: the method returns the Receiver's own immediate
verdict, and — if that was ACCEPTED — the Charge Point's eventual answer arrives later as a POST
to a response_url the Sender chose. The specification is explicit that the shape of that URL is
yours:
No structure defined. This is open to the eMSP to define, the URL is provided to the Receiver by the Sender.
For Commands that is merely open. For Charging Profiles it is load-bearing, because the three
result bodies are not distinguishable from one another: ChargingProfileResult and
ClearProfileResult are both {"result": …} and nothing else. An endpoint that sniffed the body
could not tell a rejected PUT from a rejected DELETE.
So charging_profiles_sender mounts one path per result kind, and CallbackUrls builds the
matching URLs:
let callbacks = CallbackUrls::new(my_base_url);
// Reaches ChargingProfilesSender::clear_profile_result with unique_id = "req-3".
let response_url = callbacks.clear_profile_result("req-3");
// Reaches CommandsSender::command_result — the shape the spec's own example uses,
// `.../commands/RESERVE_NOW/1234`.
let response_url = callbacks.command_result("RESERVE_NOW", &request_id);
Nothing forces you to use these; a Sender that publishes its own URLs and routes them itself is perfectly conformant. What the pair buys you is that the mount and the URL cannot drift apart — and a result posted into a 404 is a bug you find minutes later, from a Charge Point, in production.
Generate the unique id unguessably. See Security.
What it deliberately leaves to you
Persistence, and the two credentials 405 rules — only your implementation knows whether a peer is
already registered. PeerState has the predicates.
Sender and Receiver on one router
The Locations Sender interface addresses objects by id:
GET /locations/{location_id}/{evse_uid}/{connector_id}
and the Receiver interface addresses client-owned objects by party:
PUT /locations/{country_code}/{party_id}/{location_id}
Both are three path segments. A platform that is both CPO and eMSP and mounts both interfaces of
the same module on one router has an ambiguous route, and no amount of ordering fixes it. Locations
is the clearest case; Charging Profiles ({session_id} on both sides) and Payments
(terminals/{terminal_id} on both sides) have the same problem.
ServerConfig::receiver_path_prefix picks between the two ways out:
Some("receiver")— the default. One router, one/versions, and the Receiver interfaces published one segment deeper. The generated version details say so, which is the whole point of generating them.None— the conventional split: oneOcpiRouterper role, each nested under its own base URL. Set it withServerConfig::default().one_router_per_role();with_receiver_path_prefix,with_max_page_limitandwith_quirksare there too.
Mounting both interfaces of one of those modules on a prefix-less router is a configuration error, and it panics at start-up with an explanation — in either mount order — rather than producing a router that misroutes in production.
Extractors
The request-side vocabulary is available as axum extractors if you want to write handlers by hand:
Auth (the authenticated peer), Ids (request and correlation), Routing (the OCPI-to-*
headers), Page (the pagination query), Owner (the country_code/party_id path pair),
OcpiJson (a validated body) and OcpiPatch (a merge patch with its last_updated rule already
enforced).
Note that OcpiPatch is for PATCH specifically. Payments' POST .../terminals/activate carries
a partial object too — "the terminal_id is optional in the activation request" — but nothing is
being merged into anything there, so the rule that a patch must carry last_updated does not
apply, and the router decodes it separately. The handler receives a Patch<Terminal> because that
is this crate's type for "an object with fields left out"; read it with as_value, do not call
apply.
Testing your server
The testkit gives you validated sample objects, in-memory stores with
spec-accurate pagination, and MockPeer to point your client at.
Drive your server with the ocpi-kit client over a real socket, as this repository's own tests do.
A router mount and a URL builder are two independent statements about one path, in different files;
unit tests on either side pass happily while the two disagree.