1/*
2 * SPDX-License-Identifier: Apache-2.0
3 * SPDX-FileCopyrightText: 2025 The Contributors to Eclipse OpenSOVD (see CONTRIBUTORS)
4 *
5 * See the NOTICE file(s) distributed with this work for additional
6 * information regarding copyright ownership.
7 *
8 * This program and the accompanying materials are made available under the
9 * terms of the Apache License Version 2.0 which is available at
10 * https://www.apache.org/licenses/LICENSE-2.0
11 */
12
13use std::{future::Future, sync::Arc};
14
15use aide::{
16 axum::{ApiRouter as Router, routing},
17 openapi::OpenApi,
18 swagger::Swagger,
19};
20use axum::{
21 Extension, Json,
22 http::{self, Request},
23};
24use cda_interfaces::{
25 DoipGatewaySetupError, FunctionalDescriptionConfig, HashMap, SchemaProvider, UdsEcu,
26 datatypes::ComponentsConfig, diagservices::DiagServiceResponse, dlt_ctx,
27 file_manager::FileManager,
28};
29use cda_plugin_security::SecurityPluginLoader;
30use dynamic_router::DynamicRouter;
31use tokio::net::TcpListener;
32use tower::{Layer, ServiceExt as TowerServiceExt};
33use tower_http::{normalize_path::NormalizePathLayer, trace::TraceLayer};
34
35pub use crate::sovd::{
36 error::VendorErrorCode, locks::Locks, static_data::add_static_data_endpoint,
37};
38
39pub mod dynamic_router;
40mod openapi;
41pub(crate) mod sovd;
42
43// Consts for HTTP
44pub const SWAGGER_UI_ROUTE: &str = "/swagger-ui";
45pub const OPENAPI_JSON_ROUTE: &str = "/openapi.json";
46#[derive(Clone)]
47pub struct WebServerConfig {
48 pub host: String,
49 pub port: u16,
50}
51
[docs] 52/// [[ dimpl~sovd-api-http-server, Starts HTTP Server ]]
53///
54/// Launches the http(s) webserver with deferred initialization
55///
56/// The server starts immediately with static endpoints. SOVD routes and other functionality
57/// can be added later by calling methods on the returned `DynamicRouter`.
58///
59/// # Errors
60/// Will return `Err` in case that the webserver couldn't be launched.
61/// This can be caused due to invalid config, ports or addresses already being in use.
62///
63#[tracing::instrument(
64 skip(config, shutdown_signal),
65 fields(
66 host = %config.host,
67 port = %config.port,
68 )
69)]
70pub async fn launch_webserver<F>(
71 config: WebServerConfig,
72 shutdown_signal: F,
73) -> Result<(DynamicRouter, tokio::task::JoinHandle<()>), DoipGatewaySetupError>
74where
75 F: Future<Output = ()> + Clone + Send + 'static,
76{
77 let dynamic_router = DynamicRouter::new();
78 let listen_address = format!("{}:{}", config.host, config.port);
79 let listener = TcpListener::bind(&listen_address).await.map_err(|e| {
80 DoipGatewaySetupError::ServerError(format!("Failed to bind to {listen_address}: {e}"))
81 })?;
82
83 let dynamic_router_for_service = dynamic_router.clone();
84 let webserver_task = cda_interfaces::spawn_named!("webserver", async move {
85 let service = tower::service_fn(move |request: Request<axum::body::Body>| {
86 let dr = dynamic_router_for_service.clone();
87 async move {
88 let router = dr.get_router().await;
89 TowerServiceExt::oneshot(router, request).await
90 }
91 });
92
93 let middleware = tower::util::MapRequestLayer::new(rewrite_request_uri);
94 let trim_trailing_slash_middleware = NormalizePathLayer::trim_trailing_slash();
95 let service_with_middleware =
96 middleware.layer(trim_trailing_slash_middleware.layer(service));
97
98 let _ = axum::serve(listener, tower::make::Shared::new(service_with_middleware))
99 .with_graceful_shutdown(shutdown_signal)
100 .await;
101 });
102
103 Ok((dynamic_router, webserver_task))
104}
105
106/// Add vehicle routes to the dynamic router
107///
108/// This function should be called after the database is loaded to add all vehicle routes
109/// to the webserver.
110///
111/// # Errors
112/// Will return `Err` if routes cannot be added to the dynamic router.
113// type alias does not allow specifying hasher, we set the hasher globally.
114#[allow(clippy::implicit_hasher)]
115#[tracing::instrument(
116 skip(dynamic_router, ecu_uds, file_manager, locks),
117 fields(
118 flash_files_path = %flash_files_path
119 )
120)]
121pub async fn add_vehicle_routes<R, T, M, S>(
122 dynamic_router: &DynamicRouter,
123 ecu_uds: T,
124 flash_files_path: String,
125 file_manager: HashMap<String, M>,
126 locks: Arc<Locks>,
127 functional_group_config: FunctionalDescriptionConfig,
128 components_config: ComponentsConfig,
129) -> Result<(), DoipGatewaySetupError>
130where
131 R: DiagServiceResponse,
132 T: UdsEcu + SchemaProvider + Clone + Send + Sync + 'static,
133 M: FileManager + Send + Sync + 'static,
134 S: SecurityPluginLoader,
135{
136 let vehicle_router = sovd::route::<R, T, M, S>(
137 functional_group_config,
138 components_config,
139 &ecu_uds,
140 flash_files_path,
141 file_manager,
142 locks,
143 )
144 .await;
145
146 // Update the router with the new routes,
147 // merge with existing router to preserve existing routes
148 dynamic_router.merge_routes(vehicle_router).await;
149
150 tracing::info!("Vehicle routes added to webserver");
151 Ok(())
152}
153
154/// Add `OpenAPI` routes to the dynamic router, call this once all routes
155/// that should be documented are added, this will not update on further route additions and
156/// has to be called again.
157pub async fn add_openapi_routes(
158 dynamic_router: &DynamicRouter,
159 web_server_config: &WebServerConfig,
160) {
161 let server_url = format!(
162 "http://{}:{}",
163 web_server_config.host, web_server_config.port
164 );
165 let mut api = OpenApi::default();
166 dynamic_router
167 .update_router(|r| {
168 r.route(
169 SWAGGER_UI_ROUTE,
170 Swagger::new(OPENAPI_JSON_ROUTE).axum_route(),
171 )
172 .route(
173 OPENAPI_JSON_ROUTE,
174 routing::get(|Extension(api): Extension<Arc<OpenApi>>| async move {
175 Json((*api).clone())
176 }),
177 )
178 .finish_api_with(&mut api, |api| openapi::api_docs(api, server_url))
179 .layer(Extension(Arc::new(api)))
180 .into()
181 })
182 .await;
183}
184
185fn rewrite_request_uri<B>(mut req: Request<B>) -> Request<B> {
186 let uri = req.uri();
187 // Decode URI here, so we can use query params later without
188 // needing to decode them later on.
189 let decoded = percent_encoding::percent_decode_str(
190 uri.path_and_query()
191 .map(http::uri::PathAndQuery::as_str)
192 .unwrap_or_default(),
193 )
194 .decode_utf8()
195 .unwrap_or_else(|_| uri.to_string().into());
196
197 let new_uri = match decoded.to_lowercase().parse() {
198 Ok(uri) => uri,
199 Err(e) => {
200 tracing::warn!(error = %e, "Failed to parse URI, using original");
201 uri.clone()
202 }
203 };
204 *req.uri_mut() = new_uri;
205 req
206}
207
208fn create_trace_layer<S>(route: Router<S>) -> Router<S>
209where
210 S: Clone + Send + Sync + 'static,
211{
212 route.layer(
213 TraceLayer::new_for_http()
214 .make_span_with(|request: &axum::http::Request<_>| {
215 tracing::info_span!(
216 "request",
217 method = ?request.method(),
218 path = request.uri().to_string(),
219 status_code = tracing::field::Empty,
220 latency = tracing::field::Empty,
221 error = tracing::field::Empty,
222 dlt_context = dlt_ctx!("SOVD"),
223 )
224 })
225 .on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
226 tracing::debug!(
227 method = %request.method(),
228 path = %request.uri(),
229 "Request received"
230 );
231 })
232 .on_response(
233 |response: &axum::http::Response<_>,
234 latency: std::time::Duration,
235 span: &tracing::Span| {
236 span.record("status_code", response.status().as_u16());
237 span.record("latency", format!("{latency:?}"));
238 },
239 )
240 .on_failure(
241 |error: tower_http::classify::ServerErrorsFailureClass,
242 latency: std::time::Duration,
243 span: &tracing::Span| {
244 span.record("latency", format!("{latency:?}"));
245 if let tower_http::classify::ServerErrorsFailureClass::StatusCode(status) =
246 error
247 {
248 span.record("status_code", status.as_u16());
249 if status == http::StatusCode::BAD_GATEWAY {
250 return; // Ignore 502 errors
251 }
252 }
253 span.record("error", error.to_string());
254 tracing::error!("HTTP request failed");
255 },
256 ),
257 )
258}
259
260#[cfg(test)]
261pub(crate) mod test_utils {
262 use serde::de::DeserializeOwned;
263
264 pub(crate) async fn axum_response_into<T: DeserializeOwned>(
265 response: axum::response::Response,
266 ) -> Result<T, serde_json::Error> {
267 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
268 .await
269 .unwrap();
270 serde_json::from_slice::<T>(body.as_ref())
271 }
272}