Skip to main content

ocpp_service/api/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Internal HTTP API used by the rest of the platform.
5
6mod charging;
7mod commands;
8mod reservations;
9mod stations;
10mod tariffs;
11mod tokens;
12
13use axum::extract::{Request, State};
14use axum::http::header;
15use axum::middleware::{self, Next};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{get, post, put};
18use axum::{Json, Router};
19
20use crate::error::AppError;
21use crate::state::App;
22
23pub fn router(app: App) -> Router {
24    let v1 = Router::new()
25        .route("/stations", get(stations::search).post(stations::create))
26        .route(
27            "/stations/{identity}",
28            get(stations::get_one)
29                .patch(stations::update)
30                .delete(stations::delete),
31        )
32        .route("/stations/{identity}/password", put(stations::set_password))
33        .route("/stations/{identity}/evses", get(stations::evses))
34        .route(
35            "/stations/{identity}/transactions",
36            get(stations::transactions),
37        )
38        .route("/stations/{identity}/events", get(stations::events))
39        .route("/stations/{identity}/ocpp/{action}", post(commands::raw))
40        .route(
41            "/stations/{identity}/commands/{command}",
42            post(commands::generic),
43        )
44        .route(
45            "/stations/{identity}/charging-profiles",
46            get(charging::list).post(charging::set),
47        )
48        .route(
49            "/stations/{identity}/charging-profiles/clear",
50            post(charging::clear),
51        )
52        .route(
53            "/stations/{identity}/charging-profiles/sync",
54            post(charging::sync),
55        )
56        .route(
57            "/stations/{identity}/charging-profiles/{profile_id}/dynamic-update",
58            post(charging::dynamic_update),
59        )
60        .route(
61            "/stations/{identity}/charging-limits",
62            get(charging::limits),
63        )
64        .route(
65            "/stations/{identity}/composite-schedule",
66            get(charging::composite),
67        )
68        .route(
69            "/stations/{identity}/reservations",
70            get(reservations::list).post(reservations::create),
71        )
72        .route(
73            "/stations/{identity}/reservations/{id}",
74            get(reservations::get).delete(reservations::cancel),
75        )
76        .route(
77            "/stations/{identity}/tariffs",
78            get(tariffs::station_list).post(tariffs::station_set),
79        )
80        .route(
81            "/stations/{identity}/tariffs/{tariff_id}",
82            axum::routing::delete(tariffs::station_clear),
83        )
84        .route("/tariffs", get(tariffs::list))
85        .route(
86            "/tariffs/{tariff_id}",
87            get(tariffs::get).put(tariffs::put).delete(tariffs::delete),
88        )
89        .route(
90            "/transactions/{id}/tariff",
91            post(tariffs::change_transaction),
92        )
93        .route("/transactions/{id}/cost", get(tariffs::transaction_cost))
94        .route("/transactions/{id}", get(stations::transaction))
95        .route("/connections", get(connections))
96        .route("/id-tokens", get(tokens::list).put(tokens::upsert))
97        .route(
98            "/id-tokens/{token_type}/{id_token}",
99            axum::routing::delete(tokens::delete),
100        )
101        .layer(middleware::from_fn_with_state(app.clone(), require_token));
102    Router::new()
103        .route("/health", get(health))
104        .nest("/api/v1", v1)
105        .with_state(app)
106}
107
108async fn require_token(State(app): State<App>, req: Request, next: Next) -> Response {
109    if let Some(expected) = &app.cfg.api_token {
110        let ok = req
111            .headers()
112            .get(header::AUTHORIZATION)
113            .and_then(|v| v.to_str().ok())
114            .and_then(|v| v.strip_prefix("Bearer "))
115            .is_some_and(|t| constant_time_eq(t.trim().as_bytes(), expected.as_bytes()));
116        if !ok {
117            return AppError::Unauthorized.into_response();
118        }
119    }
120    next.run(req).await
121}
122
123fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
124    a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
125}
126
127async fn health(State(app): State<App>) -> Response {
128    let db_ok = sqlx::query("SELECT 1").execute(&app.db).await.is_ok();
129    let body = serde_json::json!({
130        "status": if db_ok { "ok" } else { "degraded" },
131        "node": app.cfg.node_id,
132        "connections": app.hub.len(),
133        "database": db_ok,
134    });
135    let status = if db_ok {
136        axum::http::StatusCode::OK
137    } else {
138        axum::http::StatusCode::SERVICE_UNAVAILABLE
139    };
140    (status, Json(body)).into_response()
141}
142
143async fn connections(State(app): State<App>) -> Json<serde_json::Value> {
144    Json(serde_json::json!({ "node": app.cfg.node_id, "connections": app.hub.list() }))
145}