Pure Lex library that provides the CPO↔eMSP roaming protocol layer. Within this fleet:
- lex-emsp uses it to serve the eMSP role — receiving token lookups, CDR pushes, and session notifications from CPOs, and building OCPI 2.2.1 credential handshakes
- The
src/client.lexoutbound HTTP client is the foundation for any future proactive CPO polling or eMSP-initiated credential exchange
Covers OCPI 2.1.1, 2.2.1, and 2.3.0 (including the Payments module added in 2.3.0). Pure core — no effects; HTTP transport wired in by the consuming service.
OCPI (Open Charge Point Interface) library for the Lex language: pydantic-style payload validation, role-based module catalogues (CPO / eMSP / PTP), and a fixed wire envelope — built on Lex's effect system, variant ADTs, and pure-core / effect-edge split.
Built on top of lex-schema for
payload validation. Pairs cleanly with
lex-web's router for richer HTTP
transports, but the library itself only depends on lex-schema — the
shipped example drives the OCPI dispatcher over std.net.serve_fn
directly.
Requires lex-lang 0.9.4+.
Companion library: lex-ocpp covers the CP↔CSMS side of EV charging (WebSocket-based). lex-ocpi covers the CPO↔eMSP side (HTTP/REST-based).
- OCPI response envelope (
src/envelope.lex). Encode and decode the standard{data, status_code, status_message, timestamp}wrapper every OCPI response carries; total error handling — malformed envelopes surface as a typedEnvelopeError, never a VM panic. - OCPI status codes (
src/status.lex). Wire-exact constants for every code in the spec catalog (1000-success / 2xxx-client / 3xxx-server / 4xxx-hub) plus the predicates and message map. - Request headers (
src/headers.lex). Parse + emit the eight OCPI headers (Authorization, X-Request-ID, X-Correlation-ID, plus the four-party-routingOCPI-from-/to-{country-code,party-id}). - Role catalogue (
src/role.lex). CPO / eMSP / Hub / NSP / Other / SCSP / PTP constants and a version-keyedall_roles_*()set. - Module identifiers (
src/module_id.lex). Locations / Sessions / CDRs / Tokens / Tariffs / Commands / ChargingProfiles / HubClientInfo / Credentials / Versions / Payments — per-version catalog lists. - Versions module (
src/versions.lex).Version,VersionDetail,Endpoint(with Sender/Receiver split). Stock CPO / eMSP endpoint builders so the version-detail response is one call. - Credentials module (
src/credentials.lex).Credentials,CredentialsRole,BusinessDetails,Imagetypes and the v2.2.1 validator. - Handler registry + dispatch (
src/route.lex). Register pure handlers keyed by(method, module); an optional per-route validator runs before the handler and surfaces every failing field at once as a2001envelope. - OCPI 2.2.1 surface (
src/v221/). Full enums catalog (enums.lex), and pydantic-style validators for every standard module object — Locations / EVSE / Connector, Sessions, CDRs, Tokens, Tariffs, Commands, ChargingProfiles (SetChargingProfile,ActiveChargingProfile, profile/response/result), HubClientInfo (ClientInfo). - JSON Schema → ModelSchema codegen (
tools/gen.lex). Reads an OCA-published JSON Schema doc and emits a ready-to-pasteModelSchemavalidate_<name>wrapper. Bulk-import the rest of the OCPI surface without hand-rolling every field. Coverage: primitives, arrays, enums, string-length, int-minimum, required arrays.
- Property-based test driver (
tests/test_property.lex). Generates schema-conforming payloads vialex-schema/propertyand asserts every sample validates — the schema is the spec. Runs under[random]. - Agent skill manifest (
SKILL.md). Discovery surface an LLM agent reads to emit OCPI code against this library; every entry maps to a Lex function with a stableSigId. - OCPI 2.1.1 surface (
src/v211/). Full module set: enums, Locations, Sessions, CDRs, Tokens, Tariffs, Commands, Credentials. The spec deltas vs 2.2.1 baked in: flat Credentials (noroles[]), bareauth_id(noCdrToken), noCancelReservation, smaller enum catalogues. - OCPI 2.3.0 surface (
src/v230/). Full 10-module parity: enums widened for V2X / ISO 15118-20 + DER (NEMA connector types,ISO_15118_20_PLUG_CHARGEcapability), Locations / Sessions / CDRs / Tokens / Tariffs / Commands / ChargingProfiles / HubClientInfo (with PTP role), and the new Payments module (Payment+PaymentInfo+ payment method / status enums). - Outbound HTTP client (
src/client.lex). Wrapsstd.httpwith the OCPI eight-header preset (Authorization: Token …,X-Request-ID,X-Correlation-ID, the fourOCPI-from/to-*) and the envelope-decode happy path. ReturnsClientError—HttpFailed(transport),BadEnvelope(decode),OcpiError(2xxx/3xxx/4xxx envelope). Effect:[net]. - Commands dispatch (
src/commands.lex+src/commands_async.lex). Typed ADTs over the spec's three Commands enums (CommandType— 5 variants;CommandResponseType— 4;CommandResultType— 9), the envelope records for syncCommandResponseand asyncCommandResult, andcommand_handler(handle)— receiver-side glue that lifts a pure(body, response_url) -> CommandResponseinto aroute.Handler. Sender-sidesubmit_command(...)packages the POST. URL shape{base}/commands/{TYPE}is identical across all three OCPI versions so one helper covers all of them; per-version body schemas live insrc/v{211,221,230}/commands.lex. The async runtime incommands_async.lexadds astd.concin-flight registry,wait_for_result(...)polling with deadline, the CPO-sidecallback_result(...)POST, andparse_result_post(...)for the eMSP webhook side. Closes #4. - Hub role (
src/hub.lex). The routing-table + forward- ClientInfo-broadcast trifecta from the OCPI hub model.
RoutingTablekeyed on(country_code, party_id)is a pureMap[Str, PushTarget]with CRUD helpers;forward(policy, table, from_party, to_party, method, path, body)carries[net, time]and re-issues the request to the destination peer viaclient.send_with_retry(transient failures inherit #8's retry policy). Loop prevention refusesfrom == tobefore touching the network; unknown receivers fail-fast withUnknownReceiver.RoutingErrorvariants map onto the spec's 4xxx hub-error codes (4002 unknown_receiver / 4001 loop / 4004 connection_problem) so the hub can answer the original sender with a well-shaped OCPI envelope.broadcast_clientinfofanouts aPUT /clientinfo/{cc}/{pid}/{uid}to every peer except the subject. Closes #9.
- ClientInfo-broadcast trifecta from the OCPI hub model.
- Conformance harness — assertions library
(
src/conformance.lex). Pure predicates that walk anOcpiResponse/OcpiHeaders/ response-header map and verify the OCPI wire-shape contract.check_envelope(r)enforces the spec's envelope MUSTs (status_code band, non-empty timestamp, status_message for non-1xxx codes).check_module_request_headersasserts the OCPI Authorization scheme + the 8-header tuple.check_response_echoes_requestenforces the X-Request-ID / X-Correlation-ID round-trip.check_pagination_headerscovers X-Total-Count / X-Limit + Link header. Foundation layer of issue #10; live-loop scenarios slot on top in a future PR. - Idempotency cache (
src/idempotency.lex).std.concactor backing an in-memory request cache keyed on(method, path, X-Request-ID, OCPI-from-*)per the spec.dispatch_with_cache(reg, cache, cfg, req, timestamp)is the drop-in wrapper aroundroute.dispatch— first request runs the handler and caches; duplicates return the cached response without re-invoking; concurrent duplicates poll anInFlightmarker (single-flight) with a deadline fallback. LRU + TTL bound the cache size and freshness; defaults match the OCPI spec's 24h replay window. The SQL-backed multi-replica variant ships insrc/idempotency_sql.lex(withroute_io.lex). Closes #7. - Outbound push fanout (
src/push.lex).PushKindADT — 8 variants covering the OCPI-side CPO→eMSP push catalogue (Location PUT/PATCH, EVSE patch, Connector patch, Session PUT/PATCH, CDR POST, Token PUT). Pure URL / method / body helpers per kind, pluspush(policy, from_party, target, kind)for single-target send throughclient.send_with_retryandpush_fanout(...)for N targets vialist.map. The retry policy threads in unchanged — transient failures hit the classifier from #8; a single target failing returns a per-targetResultlist and does not short-circuit the others. Closes #5. - Retry + backoff in the outbound client (
src/client.lex).RetryPolicyrecord (max attempts, initial / max delay, integer multiplier × 100, jitter, respect-Retry-After).send_with_retryis the[net, time]loop;send_with_eventsadds an[io]observer that fires on every planned retry and the final give-up. Classifieris_retryable(err)covers HTTP 408 / 429 / 5xx + transport failures; 4xx-other / malformed-envelope / OCPI-logical-errors don't retry. HonoursRetry-After: <seconds>on 429 / 503 (HTTP-date form unsupported — integer-seconds is what the OCPI ecosystem actually ships). Closes #8. - Real-time token authorization (
src/authorize.lex+src/v{211,221,230}/authorize.lex). Both sides of thePOST /tokens/.../authorizeflow that runs before every charge session start: a sharedAuthorizationResultADT (Allowed | Blocked | Expired | NoCredit | NotAllowedwrapping the validatedAuthorizationInfo); per-version URL builders reflecting the path-shape delta between v2.1.1 and v2.2.1+v2.3.0; abody_validatorthat accepts null / empty{}per spec; anauthorize_handler(authorize)that lifts a pure(token_uid, Option[location_refs]) -> AuthorizationResultinto aroute.Handler; and a sender-sideauthorize_token(...)that POSTs and decodes the response. Effect: pure for everything exceptauthorize_token([net]). - Effectful registry (
src/route_io.lex). Lex-ocpp parity: handlers carry an[io, time, sql]upper bound so they can log viaio.print, stamplast_updated, and persist via lex-orm's[sql]-flavoured helpers. - Pagination (
src/pagination.lex).PageRequest { offset, limit }parsed from a query map with sane defaults (offset=0, limit=50) and negative-clamping;clamp_limitfor the spec-mandated server cap;paginatereturns aPage { items, offset, limit, total };headersemits the standard OCPI shape (X-Total-Count,X-Limit, and aLink: <url>; rel="next"when more pages exist). - Date-range filters (
src/filters.lex).DateRange { date_from, date_to }parsed from a query map;apply(items, range)drops items outside[date_from, date_to)via lexicographic ISO-8601 comparison — the other half of every OCPI list endpoint's contract (?date_from=/?date_to=alongside?offset=/?limit=).
import "lex-ocpi/route" as route
import "lex-ocpi/envelope" as env
import "lex-ocpi/module_id" as mid
import "lex-ocpi/v221/locations" as locs
import "lex-schema/json_value" as jv
import "std.time" as time
# Handlers are pure — request in, response payload out.
fn get_locations(_req :: route.OcpiRequest) -> route.HandlerResult {
route.ok_list([]) # real CPOs page through their location DB here
}
# Build the registry — schema validation runs before the handler.
fn registry() -> route.Registry {
route.new()
|> fn (r) { route.handler(r, route.get(), mid.locations(), get_locations) }
}
# Pure dispatch — takes an OcpiRequest, returns an OcpiResponse.
# Pair with lex-web's router for the HTTP transport (see
# `examples/cpo_v221.lex`).
fn handle(req :: route.OcpiRequest) -> [time] env.OcpiResponse {
route.dispatch(registry(), req, time.now_str())
}Run the example over real HTTP:
lex run --allow-effects net,io,time examples/cpo_v221.lex main
# Listening on http://localhost:9100/ocpi/...
curl -H "Authorization: Token cpo-secret" \
http://localhost:9100/ocpi/versions
# {"data":[{"version":"2.2.1","url":"..."}],"status_code":1000,"timestamp":"..."}
curl -H "Authorization: Token cpo-secret" \
http://localhost:9100/ocpi/2.2.1/locations/LOC1
# {"data":{"country_code":"NL","party_id":"EXM",...},"status_code":1000,...}lex.toml package manifest (lex 0.9.4+)
src/
envelope.lex OCPI response envelope (data/status_code/message/timestamp)
status.lex Status code constants + predicates + message map
error.lex OcpiError ADT + schema-error adapter
role.lex CPO / EMSP / Hub / NSP / Other / SCSP / PTP
module_id.lex Module identifier strings
interface_role.lex Sender / Receiver
party.lex PartyId (country_code + party_id)
headers.lex OCPI request header parsing/building
versions.lex Versions + VersionDetail + Endpoint
credentials.lex Credentials handshake objects + schema
route.lex Pure handler registry + dispatch
route_io.lex Effectful registry (`[io, time, sql]` upper bound)
client.lex Outbound OCPI HTTP client (`[net]`) + retry/backoff (`[net, time]`) + handshake
push.lex CPO→eMSP state-change fanout — PushKind ADT + single/N-target push
idempotency.lex In-memory request cache — LRU + TTL + single-flight dispatch wrapper
conformance.lex Spec-conformance assertions — envelope / headers / pagination
hub.lex Hub role — routing table + forward + ClientInfo broadcast
commands.lex Commands ADTs + receiver/sender dispatch (sync half)
commands_async.lex In-flight actor + wait_for_result + callback-POST glue
authorize.lex Shared AuthorizationResult ADT + decode/encode
pagination.lex ?offset/?limit parsing + Page + Link/X-Total-Count headers
filters.lex ?date_from/?date_to ISO-8601 range filtering
v211/ OCPI 2.1.1 surface — full (enums + credentials +
locations + sessions + tokens +
cdrs + tariffs + commands +
authorize)
v221/
enums.lex OCPI 2.2.1 enums (LocationType, ConnectorType, ...)
locations.lex Location + EVSE + Connector schemas
sessions.lex Session + CdrToken + ChargingPeriod schemas
cdrs.lex CDR + CdrLocation + SignedData schemas
tokens.lex Token + AuthorizationInfo schemas
tariffs.lex Tariff + TariffElement + PriceComponent schemas
commands.lex Start/Stop/Reserve/Cancel/Unlock + response schemas
chargingprofiles.lex ChargingProfile + Set/Active/Result schemas
hubclientinfo.lex ClientInfo + ConnectionStatus enum
authorize.lex Real-time POST /tokens/{cc}/{pid}/{uid}/authorize
v230/ OCPI 2.3.0 surface — full (enums + 9 modules
+ Payments NEW)
enums.lex V2X / ISO 15118-20 plug-charge / NEMA connectors
locations.lex Location + EVSE + Connector with v2.3 enum widening
sessions.lex Session + CdrToken + ChargingPeriod
cdrs.lex CDR + CdrLocation + SignedData
tokens.lex Token + AuthorizationInfo
tariffs.lex Tariff + TariffElement
commands.lex Start/Stop/Reserve/Cancel/Unlock
chargingprofiles.lex ChargingProfile + Set/Active/Result
hubclientinfo.lex ClientInfo (with PTP role)
payments.lex Payment + PaymentInfo + PaymentReference (NEW)
authorize.lex Real-time POST /tokens/{cc}/{pid}/{uid}/authorize
tools/
gen.lex JSON Schema → ModelSchema codegen
tests/
test_envelope.lex Envelope encode / parse / round-trip
test_status.lex Status code constants + predicates
test_headers.lex Header from_map / to_map / token extraction
test_versions.lex Versions discovery JSON shape
test_credentials.lex Credentials validator
test_route.lex Dispatcher + validator wiring
test_client.lex Outbound HTTP client header builders
test_authorize.lex Token-authorize ADT + handler + URL/body builders
test_commands_dispatch.lex Commands ADTs + response/result envelopes + handler
test_commands_async.lex In-flight actor + wait/timeout + webhook parser
test_retry.lex Retry classifier + backoff math + Retry-After parsing
test_push.lex Push fanout — PushKind method/URL/body + request shape
test_idempotency.lex Idempotency cache — handler/LRU/actor/dispatch wrapper
test_conformance.lex Conformance harness — envelope/header/pagination assertions
test_hub.lex Hub routing table + forward + error mapping
test_pagination.lex PageRequest parse + paginate + headers
test_filters.lex DateRange parse + apply + str ordering
test_v211_schemas.lex v2.1.1 spec-delta validators
test_v211_more.lex v2.1.1 Tariff / Command / CDR validators
test_v221_schemas.lex v2.2.1 per-object validator tests
test_v221_hubchargingprofiles.lex ChargingProfiles + HubClientInfo validators
test_v230_schemas.lex v2.3.0 validators (Payments, PTP role, ...)
test_gen.lex JSON Schema → ModelSchema codegen
test_property.lex Property-based fuzz driver (random)
examples/
cpo_v221.lex Minimal OCPI 2.2.1 CPO over HTTP
emsp_client.lex eMSP-side discovery + read using src/client.lex
export_schemas.lex Schema → TS / Pydantic / JSON Schema / OpenAPI
SKILL.md Agent skill manifest
The dispatcher is pure. Envelope construction, validation, handler
lookup, and response packaging never touch [io], [net], or
[time]. Effects live at the transport boundary: the HTTP server
adapter in your main() function declares [net, io, time] and
calls into the pure core.
This matches lex-ocpp's split (route pure, route_io effectful)
and lex-web's split (dispatch_pure vs dispatch). It makes the
library fully testable without a transport, and lets users compose
the core with whatever transport / persistence layer they prefer.
The dispatcher's signature takes a timestamp :: Str argument
rather than reaching for time.now_str(): tests pass a fixed
timestamp for deterministic golden fixtures; production passes
time.now_str() from the transport adapter (the [time] effect
sits at the edge).
OCPI enums (LocationType, ConnectorType, TokenType, SessionStatus,
CommandResult, …) are exposed as fn name() -> Str constants and
reflected at the validation boundary via lex-schema's
StrOneOf(all_xxx()). Three concrete payoffs over closure-based
validation:
- Inspectable by
lex audit.lex audit --calls StrOneOflists every enum-bounded field in your codebase. Closures vanish. - Codegen-friendly. Pass any of these schemas to
lex-schema/sdkand get TypeScript / Python / SQL DDL for free — the OCPI datatypes round-trip through the same pipeline as any other validated payload. - Cheaper. A variant is a tagged record; a closure carries captures plus an indirect call.
Extension is open: add a StrOneOf(["MY_CUSTOM_TYPE"]) constraint
to your validator without forking lex-ocpi. The spec leaves room for
vendor extensions on most enums (Capability, ConnectorType, …), so a
closed Lex sum would be the wrong shape.
A malformed Location payload returns every failing field at once —
not the first one. This matches pydantic's ValidationError shape:
a UI rendering the response can highlight every failing field in a
single pass, not require N round-trips.
PropertyConstraintViolation {
violations: [
{ path: "address", code: "min_len",
message: "must be at least 1 characters" },
{ path: "coordinates.latitude", code: "max_len",
message: "must be at most 10 characters" },
]
}
OCPI 2.1.1 / 2.2.1 / 2.3.0 share the same wire-level envelope and status code catalog; they differ on which modules exist and on the exact shape of some objects (CDR field renames, Token shape, the addition of Payments in 2.3.0). lex-ocpi:
- shares
src/envelope.lex,src/status.lex,src/headers.lex,src/route.lex,src/versions.lex,src/credentials.lex,src/pagination.lex,src/filters.lex,src/client.lexbetween all three versions, - exposes
src/v211/,src/v221/, andsrc/v230/side by side, - exposes the per-version role catalog (
role.all_roles_v211()/role.all_roles_v221()/role.all_roles_v230()) so a single peer can advertise the right set per version.
OCPI module coverage by version:
| Version | Modules | Coverage |
|---|---|---|
| 2.1.1 | enums + credentials + locations + sessions + tokens + cdrs + tariffs + commands | 8/8 |
| 2.2.1 | + chargingprofiles + hubclientinfo | 10/10 |
| 2.3.0 | + payments (new module), V2X / ISO 15118-20 enum widening, PTP role | 10/10 |
The pure path is fully effect-free; the HTTP-transport entry points
declare [net, io, time]:
| Function | Effects |
|---|---|
envelope.encode / envelope.parse |
none |
route.dispatch |
none (timestamp is an arg) |
headers.from_map / headers.to_map |
none |
versions.detail_to_json |
none |
credentials.validate_credentials_v221 |
none |
v211/*.validate_* / v221/* / v230/* |
none |
pagination.* / filters.* |
none |
| handler bodies (pure registry) | none |
route_io.dispatch |
[io, time, sql] |
client.send / client.get_with_token / ... |
[net] |
client.handshake |
[net] |
examples/cpo_v221.main |
[net, io, time] |
examples/emsp_client.main |
[net, io] |
Pure modules + pure tests run without any --allow-effects flag.
Examples that drive an HTTP server / client need net,io,time.
- Combined OCPP + OCPI worked example. A CPO that terminates
OCPP on one side and serves OCPI on the other — open at
#3. The two
libraries compose today (lex-ocpp's
StartTransactionhandler writes a Session via lex-ocpi); the example would just wire them end-to-end. - Upstream lex-lang gaps surfaced while building this library
and tracked there:
?/trysugar forResult/Optionearly-return (lex-lang#435),std.netmiddleware seam (lex-lang#436), match guard clauses (lex-lang#438), parametric record-alias coercion fortype Page[T] = { ... }(lex-lang#439). None of these are blocking — they would letpagination.lexdrop a few workarounds and letclient.lexshorten its error-propagation chains.
OCPI CPO implementations almost always:
- persist Locations / EVSEs / Sessions / CDRs — pair with
lex-orm (typed query builder
- migration runner on top of
std.sql). Because OCPI payload schemas arelex-schemaModelSchemavalues, you can drivelex-orm'sRepo[T]off the same schema.
- migration runner on top of
- drive their chargers over OCPP — pair with
lex-ocpp. lex-ocpi
consumes the output of lex-ocpp: when a
StartTransactionlands on the OCPP side, the CPO writes a Session via lex-ocpi to the eMSP that owns the token used; whenStopTransactionlands, the CPO emits a CDR.
A worked example combining both — a CPO that terminates OCPP on one side and serves OCPI on the other — is open follow-up (#3).
EUPL-1.2 — to match the parent lex-lang ecosystem.