codetics-ocpp-service 0.1.0

Codetics OCPP: Charging Station Management System (CSMS) service for OCPP 1.6J, 2.0.1 and 2.1, with TLS security profiles, smart charging, reservations and tariffs
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! Internal HTTP API used by the rest of the platform.

mod charging;
mod commands;
mod reservations;
mod stations;
mod tariffs;
mod tokens;

use axum::extract::{Request, State};
use axum::http::header;
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use axum::{Json, Router};

use crate::error::AppError;
use crate::state::App;

pub fn router(app: App) -> Router {
    let v1 = Router::new()
        .route("/stations", get(stations::search).post(stations::create))
        .route(
            "/stations/{identity}",
            get(stations::get_one)
                .patch(stations::update)
                .delete(stations::delete),
        )
        .route("/stations/{identity}/password", put(stations::set_password))
        .route("/stations/{identity}/evses", get(stations::evses))
        .route(
            "/stations/{identity}/transactions",
            get(stations::transactions),
        )
        .route("/stations/{identity}/events", get(stations::events))
        .route("/stations/{identity}/ocpp/{action}", post(commands::raw))
        .route(
            "/stations/{identity}/commands/{command}",
            post(commands::generic),
        )
        .route(
            "/stations/{identity}/charging-profiles",
            get(charging::list).post(charging::set),
        )
        .route(
            "/stations/{identity}/charging-profiles/clear",
            post(charging::clear),
        )
        .route(
            "/stations/{identity}/charging-profiles/sync",
            post(charging::sync),
        )
        .route(
            "/stations/{identity}/charging-profiles/{profile_id}/dynamic-update",
            post(charging::dynamic_update),
        )
        .route(
            "/stations/{identity}/charging-limits",
            get(charging::limits),
        )
        .route(
            "/stations/{identity}/composite-schedule",
            get(charging::composite),
        )
        .route(
            "/stations/{identity}/reservations",
            get(reservations::list).post(reservations::create),
        )
        .route(
            "/stations/{identity}/reservations/{id}",
            get(reservations::get).delete(reservations::cancel),
        )
        .route(
            "/stations/{identity}/tariffs",
            get(tariffs::station_list).post(tariffs::station_set),
        )
        .route(
            "/stations/{identity}/tariffs/{tariff_id}",
            axum::routing::delete(tariffs::station_clear),
        )
        .route("/tariffs", get(tariffs::list))
        .route(
            "/tariffs/{tariff_id}",
            get(tariffs::get).put(tariffs::put).delete(tariffs::delete),
        )
        .route(
            "/transactions/{id}/tariff",
            post(tariffs::change_transaction),
        )
        .route("/transactions/{id}/cost", get(tariffs::transaction_cost))
        .route("/transactions/{id}", get(stations::transaction))
        .route("/connections", get(connections))
        .route("/id-tokens", get(tokens::list).put(tokens::upsert))
        .route(
            "/id-tokens/{token_type}/{id_token}",
            axum::routing::delete(tokens::delete),
        )
        .layer(middleware::from_fn_with_state(app.clone(), require_token));
    Router::new()
        .route("/health", get(health))
        .nest("/api/v1", v1)
        .with_state(app)
}

async fn require_token(State(app): State<App>, req: Request, next: Next) -> Response {
    if let Some(expected) = &app.cfg.api_token {
        let ok = req
            .headers()
            .get(header::AUTHORIZATION)
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.strip_prefix("Bearer "))
            .is_some_and(|t| constant_time_eq(t.trim().as_bytes(), expected.as_bytes()));
        if !ok {
            return AppError::Unauthorized.into_response();
        }
    }
    next.run(req).await
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}

async fn health(State(app): State<App>) -> Response {
    let db_ok = sqlx::query("SELECT 1").execute(&app.db).await.is_ok();
    let body = serde_json::json!({
        "status": if db_ok { "ok" } else { "degraded" },
        "node": app.cfg.node_id,
        "connections": app.hub.len(),
        "database": db_ok,
    });
    let status = if db_ok {
        axum::http::StatusCode::OK
    } else {
        axum::http::StatusCode::SERVICE_UNAVAILABLE
    };
    (status, Json(body)).into_response()
}

async fn connections(State(app): State<App>) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "node": app.cfg.node_id, "connections": app.hub.list() }))
}