1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14use std::{
15 future::Future,
16 sync::{Arc, atomic::AtomicBool},
17};
18
19use aide::{axum::routing, swagger::Swagger};
20use axum::{
21 Json,
22 http::{self, Request},
23};
24use cda_interfaces::{
25 DoipGatewaySetupError, FunctionalDescriptionConfig, HashMap, SchemaProvider, UdsEcu,
26 datatypes::ComponentsConfig, dlt_ctx, file_manager::FileManager,
27};
28use cda_plugin_security::SecurityPluginLoader;
29use dynamic_router::DynamicRouter;
30pub use dynamic_router::{RouteGroupNotFound, RouteHandle};
31pub use http::Method;
32use tokio::net::TcpListener;
33use tower::{Layer, ServiceExt as TowerServiceExt};
34use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
35
36/// Public API surface re-exported from the crate-internal `sovd` module.
37pub use crate::sovd::{
38 EcuExecutionRegistry, SovdLockStateProvider,
39 apps::sovd2uds::bulk_data::runtimefiles::RuntimeUpdateRouteState,
40 error::VendorErrorCode,
41 locks::Locks,
42 static_data::add_static_data_endpoint,
43 update_guard::{ExemptRoute, UpdateGuardLayer, UpdateGuardState},
44};
45pub mod dynamic_router;
46mod openapi;
47pub(crate) mod sovd;
48
49// Consts for HTTP
50pub const SWAGGER_UI_ROUTE: &str = "/swagger-ui";
51pub const OPENAPI_JSON_ROUTE: &str = "/openapi.json";
52#[derive(Clone)]
53pub struct WebServerConfig {
54 pub host: String,
55 pub port: u16,
56}
57
58/// Static configuration for vehicle SOVD routes.
59pub struct VehicleConfig {
60 pub flash_files_path: String,
61 pub functional_group_config: FunctionalDescriptionConfig,
62 pub components_config: ComponentsConfig,
63}
64
65/// Runtime resources (handles, shared state) for vehicle SOVD routes.
66pub struct VehicleResources<T, M> {
67 pub ecu_uds: T,
68 pub file_manager: HashMap<String, M>,
69 pub locks: Arc<Locks>,
70 pub update_in_progress: Arc<AtomicBool>,
71}
72
[docs] 73/// [[ dimpl~sovd-api-http-server, Starts HTTP Server ]]
74///
75/// Launches the http(s) webserver with deferred initialization
76///
77/// The server starts immediately with static endpoints. SOVD routes and other functionality
78/// can be added later by calling methods on the returned `DynamicRouter`.
79///
80/// # Errors
81/// Will return `Err` in case that the webserver couldn't be launched.
82/// This can be caused due to invalid config, ports or addresses already being in use.
83///
84#[tracing::instrument(
85 skip(config, shutdown_signal),
86 fields(
87 host = %config.host,
88 port = %config.port,
89 )
90)]
91pub async fn launch_webserver<F>(
92 config: WebServerConfig,
93 shutdown_signal: F,
94) -> Result<(DynamicRouter, tokio::task::JoinHandle<()>), DoipGatewaySetupError>
95where
96 F: Future<Output = ()> + Clone + Send + 'static,
97{
98 let dynamic_router = DynamicRouter::new();
99 let listen_address = format!("{}:{}", config.host, config.port);
100 let listener = TcpListener::bind(&listen_address).await.map_err(|e| {
101 DoipGatewaySetupError::ServerError(format!("Failed to bind to {listen_address}: {e}"))
102 })?;
103
104 let dynamic_router_for_service = dynamic_router.clone();
105 let webserver_task = cda_interfaces::spawn_named!("webserver", async move {
106 let service = tower::service_fn(move |request: Request<axum::body::Body>| {
107 let dr = dynamic_router_for_service.clone();
108 async move {
109 let router = dr.get_router().await;
110 TowerServiceExt::oneshot(router, request).await
111 }
112 });
113
114 let middleware = tower::util::MapRequestLayer::new(rewrite_request_uri);
115 let trim_trailing_slash_middleware = NormalizePathLayer::trim_trailing_slash();
116 let service_with_middleware =
117 middleware.layer(trim_trailing_slash_middleware.layer(service));
118
119 let _ = axum::serve(listener, tower::make::Shared::new(service_with_middleware))
120 .with_graceful_shutdown(shutdown_signal)
121 .await;
122 });
123
124 Ok((dynamic_router, webserver_task))
125}
126
127/// Add vehicle routes to the dynamic router
128///
129/// This function should be called after the database is loaded to add all vehicle routes
130///
131/// # Errors
132/// Returns `Err` if routes cannot be added to the dynamic router.
133#[allow(
134 clippy::implicit_hasher,
135 reason = "Type alias doesn't allow specifying hasher"
136)]
137#[tracing::instrument(
138 skip(dynamic_router, config, resources),
139 fields(
140 flash_files_path = %config.flash_files_path
141 )
142)]
143pub async fn add_vehicle_routes<T, M, S>(
144 dynamic_router: &DynamicRouter,
145 config: VehicleConfig,
146 resources: VehicleResources<T, M>,
147) -> Result<(EcuExecutionRegistry, RouteHandle), DoipGatewaySetupError>
148where
149 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
150 M: FileManager + Send + Sync + 'static,
151 S: SecurityPluginLoader,
152{
153 let (vehicle_router, registry) = build_vehicle_routes::<T, M, S>(config, resources).await;
154
155 let handle = dynamic_router.add_routes(vehicle_router).await;
156
157 tracing::info!("Vehicle routes added to webserver");
158 Ok((registry, handle))
159}
160
161#[allow(
162 clippy::implicit_hasher,
163 reason = "Type alias doesn't allow specifying hasher"
164)]
165pub async fn build_vehicle_routes<T, M, S>(
166 config: VehicleConfig,
167 resources: VehicleResources<T, M>,
168) -> (aide::axum::ApiRouter, EcuExecutionRegistry)
169where
170 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
171 M: FileManager + Send + Sync + 'static,
172 S: SecurityPluginLoader,
173{
174 let (router, registry) = sovd::route::<T, M, S>(
175 config.functional_group_config,
176 config.components_config,
177 &resources.ecu_uds,
178 config.flash_files_path,
179 resources.file_manager,
180 resources.locks,
181 resources.update_in_progress,
182 )
183 .await;
184 (router, registry)
185}
186
187/// Mounts the runtime-update HTTP routes onto the dynamic router and returns a handle to them.
188///
189/// Adds the runtime-file update endpoints to the router, registers exempt routes on the
190/// [`UpdateGuardState`], and logs when the routes become active.
191pub async fn add_runtime_update_routes<S, P, L>(
192 dynamic_router: &DynamicRouter,
193 plugin: Arc<P>,
194 lock_state: Arc<L>,
195 update_guard: &UpdateGuardState,
196 upload_limit: usize,
197 retry_after_seconds: u64,
198) -> RouteHandle
199where
200 S: SecurityPluginLoader,
201 P: cda_interfaces::runtime_update_api::RuntimeFilesUpdatePlugin,
202 L: cda_interfaces::runtime_update_api::LockStateProvider,
203{
204 update_guard
205 .extend_exempt(sovd::apps::sovd2uds::bulk_data::runtimefiles::update_exempt_routes())
206 .await;
207
208 let route_state = RuntimeUpdateRouteState {
209 plugin,
210 vehicle_lock_states: lock_state,
211 retry_after_seconds,
212 };
213 let router =
214 sovd::apps::sovd2uds::bulk_data::runtimefiles::routes::<S, P, L>(route_state, upload_limit);
215 let handle = dynamic_router.add_routes(router.into()).await;
216 tracing::info!("Runtime update routes added to webserver");
217 handle
218}
219
220/// `OpenAPI` spec regenerates on every recomposition, reflecting current routes.
221pub async fn add_openapi_routes(
222 dynamic_router: &DynamicRouter,
223 _update_guard: &UpdateGuardState,
224 web_server_config: &WebServerConfig,
225) {
226 let server_url = format!(
227 "http://{}:{}",
228 web_server_config.host, web_server_config.port
229 );
230 let dr = dynamic_router.clone();
231 dynamic_router
232 .add_finalizer(Arc::new(move |router: axum::Router| -> axum::Router {
233 let server_url = server_url.clone();
234 let dr = dr.clone();
235 let swagger_route: axum::routing::MethodRouter =
236 Swagger::new(OPENAPI_JSON_ROUTE).axum_route().into();
237 let openapi_route: axum::routing::MethodRouter = routing::get(move || async move {
238 let mut api = (*dr.get_openapi().await).clone();
239 let _ =
240 openapi::api_docs(aide::transform::TransformOpenApi::new(&mut api), server_url);
241 Json(api)
242 })
243 .into();
244 router
245 .route(SWAGGER_UI_ROUTE, swagger_route)
246 .route(OPENAPI_JSON_ROUTE, openapi_route)
247 }))
248 .await;
249}
250
251pub async fn install_update_guard(dynamic_router: &DynamicRouter, update_guard: UpdateGuardState) {
252 let layer = UpdateGuardLayer::new(update_guard);
253 dynamic_router
254 .add_finalizer(Arc::new(move |router: axum::Router| -> axum::Router {
255 router.layer(layer.clone())
256 }))
257 .await;
258}
259
260fn rewrite_request_uri<B>(mut req: Request<B>) -> Request<B> {
261 let uri = req.uri();
262 // Decode URI here, so we can use query params later without
263 // needing to decode them later on.
264 let decoded = percent_encoding::percent_decode_str(
265 uri.path_and_query()
266 .map(http::uri::PathAndQuery::as_str)
267 .unwrap_or_default(),
268 )
269 .decode_utf8()
270 .unwrap_or_else(|_| uri.to_string().into());
271
272 let new_uri = match decoded.to_lowercase().parse() {
273 Ok(uri) => uri,
274 Err(e) => {
275 tracing::warn!(error = %e, "Failed to parse URI, using original");
276 uri.clone()
277 }
278 };
279 *req.uri_mut() = new_uri;
280 req
281}
282
283fn create_trace_layer<S>(route: axum::Router<S>) -> axum::Router<S>
284where
285 S: Clone + Send + Sync + 'static,
286{
287 route.layer(
288 TraceLayer::new_for_http()
289 .make_span_with(|request: &axum::http::Request<_>| {
290 tracing::info_span!(
291 "request",
292 method = ?request.method(),
293 path = request.uri().to_string(),
294 status_code = tracing::field::Empty,
295 latency = tracing::field::Empty,
296 error = tracing::field::Empty,
297 dlt_context = dlt_ctx!("SOVD"),
298 )
299 })
300 .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
301 tracing::debug!(
302 method = %request.method(),
303 path = %request.uri(),
304 "Request received"
305 );
306 })
307 .on_response(
308 |response: &axum::http::Response<_>,
309 latency: std::time::Duration,
310 span: &tracing::Span| {
311 span.record("status_code", response.status().as_u16());
312 span.record("latency", format!("{latency:?}"));
313 },
314 )
315 .on_failure(
316 |error: tower_http::classify::ServerErrorsFailureClass,
317 latency: std::time::Duration,
318 span: &tracing::Span| {
319 span.record("latency", format!("{latency:?}"));
320 if let tower_http::classify::ServerErrorsFailureClass::StatusCode(status) =
321 error
322 {
323 span.record("status_code", status.as_u16());
324 if status == http::StatusCode::BAD_GATEWAY {
325 return; // Ignore 502 errors
326 }
327 }
328 span.record("error", error.to_string());
329 tracing::error!("HTTP request failed");
330 },
331 ),
332 )
333}
334
335#[cfg(test)]
336pub(crate) mod test_utils {
337 use serde::de::DeserializeOwned;
338
339 pub(crate) async fn axum_response_into<T: DeserializeOwned>(
340 response: axum::response::Response,
341 ) -> Result<T, serde_json::Error> {
342 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
343 .await
344 .unwrap();
345 serde_json::from_slice::<T>(body.as_ref())
346 }
347}