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}