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::sync::Arc;
15
16use aide::axum::{ApiRouter, routing};
17use axum::{Json, http::StatusCode};
18use cda_sovd::dynamic_router::DynamicRouter;
19use futures::FutureExt;
20use opensovd_cda_lib::{cda_version, config::configfile::ServerTransport};
21use reqwest::Method;
22use serde::{Deserialize, Serialize};
23
24use crate::util::{
25 TestingError,
26 http::{Response, response_to_t},
27 runtime::{find_available_tcp_port, host, wait_for_cda_online},
28};
29
30const MAIN_HEALTH_COMPONENT_KEY: &str = "main";
31
32#[derive(Serialize, Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq)]
33struct TestData {
34 oem_name: String,
35 version: String,
36}
37
38async fn add_custom_routes(dynamic_router: &DynamicRouter) {
39 let custom_router = ApiRouter::new().api_route(
40 "/test",
41 routing::get_with(
42 || async {
43 (
44 StatusCode::OK,
45 Json(TestData {
46 oem_name: "Eclipse Foundation".to_string(),
47 version: "1.0.0".to_string(),
48 }),
49 )
50 },
51 |op| {
52 // OpenAPI documentation for the GET /demo endpoint
53 op.description("Get demo data")
54 .response_with::<200, Json<TestData>, _>(|res| {
55 res.example(TestData {
56 oem_name: "Eclipse Foundation".to_string(),
57 version: "1.0.0".to_string(),
58 })
59 })
60 },
61 )
62 .post_with(
63 |Json(payload): Json<TestData>| async move {
64 // Echo back the payload
65 (StatusCode::CREATED, Json(payload))
66 },
67 |op| {
68 op.description("Create demo data")
69 .response_with::<201, Json<TestData>, _>(|res| {
70 res.description("Successfully created")
71 })
72 },
73 ),
74 );
75
76 dynamic_router.add_routes(custom_router).await;
77}
78
79fn shutdown_channel() -> (
80 tokio::sync::broadcast::Sender<()>,
81 impl Future<Output = ()> + Clone + Send + 'static,
82) {
83 let (tx, mut rx) = tokio::sync::broadcast::channel::<()>(1);
84 let signal = async move {
85 rx.recv().await.ok();
86 }
87 .shared();
88 (tx, signal)
89}
90
91async fn shutdown_and_join(
92 shutdown_tx: tokio::sync::broadcast::Sender<()>,
93 webserver_join_handle: tokio::task::JoinHandle<()>,
94) {
95 shutdown_tx.send(()).ok();
96 webserver_join_handle
97 .await
98 .expect("Failed to shutdown webserver");
99}
100
101/// Exercises the `/test` demo endpoint via `get`/`post` (parameterized so
102/// callers can send the requests over TCP or a Unix domain socket) and
103/// asserts on the round-tripped `TestData` payloads.
104async fn assert_demo_endpoint_get_post<GetFut>(
105 get: impl FnOnce() -> GetFut,
106 post: impl FnOnce(String) -> futures::future::BoxFuture<'static, Result<Response, TestingError>>,
107) where
108 GetFut: Future<Output = Result<Response, TestingError>>,
109{
110 let get_response = get().await.expect("GET request failed");
111 let demo_data: TestData = response_to_t(&get_response).expect("Failed to parse GET response");
112 assert_eq!(demo_data.oem_name, "Eclipse Foundation");
113 assert_eq!(demo_data.version, "1.0.0");
114
115 let post_payload = TestData {
116 oem_name: "Custom OEM".to_string(),
117 version: "2.0.0".to_string(),
118 };
119 let post_body = serde_json::to_string(&post_payload).expect("Failed to serialize payload");
120 let post_response = post(post_body).await.expect("POST request failed");
121
122 let response_data: TestData =
123 response_to_t(&post_response).expect("Failed to parse POST response");
124 assert_eq!(response_data, post_payload);
125}
126
127#[tokio::test]
128async fn test_custom_demo_endpoint() {
129 // Use loopback since we don't need actual ECU connections for this test
130 let host = host();
131 let test_port = find_available_tcp_port(&host).expect("Failed to find available port");
132
133 let webserver_config = cda_sovd::WebServerConfig::Tcp {
134 host: host.clone(),
135 port: test_port,
136 };
137
138 let (shutdown_tx, shutdown_signal) = shutdown_channel();
139
140 let (dynamic_router, webserver_join_handle) =
141 cda_sovd::launch_webserver(webserver_config, shutdown_signal.clone())
142 .await
143 .expect("Failed to launch webserver");
144
145 let health = cda_health::add_health_routes(&dynamic_router, cda_version().to_owned()).await;
146 let main_health_provider = {
147 let provider = Arc::new(cda_health::StatusHealthProvider::new(
148 cda_health::Status::Starting,
149 ));
150 health
151 .register_provider(
152 MAIN_HEALTH_COMPONENT_KEY,
153 Arc::clone(&provider) as Arc<dyn cda_health::HealthStatus>,
154 )
155 .await
156 .expect("Failed to register main health provider");
157 provider
158 };
159
160 // Add custom routes directly. No vehicle routes are needed for this test.
161 add_custom_routes(&dynamic_router).await;
162
163 main_health_provider
164 .update_status(cda_health::Status::Up)
165 .await;
166
167 let url = reqwest::Url::parse(&format!("http://{host}:{test_port}/test")).expect("Invalid URL");
168 wait_for_cda_online(&ServerTransport::Tcp {
169 address: host,
170 port: test_port,
171 unix_socket: None,
172 })
173 .await
174 .expect("Webserver did not start in time");
175
176 assert_demo_endpoint_get_post(
177 || crate::util::http::send_request(StatusCode::OK, Method::GET, None, None, url.clone()),
178 |body| {
179 let url = url.clone();
180 async move {
181 crate::util::http::send_request(
182 StatusCode::CREATED,
183 Method::POST,
184 Some(&body),
185 None,
186 url,
187 )
188 .await
189 }
190 .boxed()
191 },
192 )
193 .await;
194
195 shutdown_and_join(shutdown_tx, webserver_join_handle).await;
196}
197
[docs]198/// [[ itest~sovd-api-http-server-unix-socket, End-to-end GET/POST over a Unix domain socket bound webserver, itest ]]
199#[cfg(unix)]
200#[tokio::test]
201async fn test_custom_demo_endpoint_over_unix_socket() {
202 let socket_dir = tempfile::tempdir().expect("Failed to create temp dir");
203 let socket_path = socket_dir
204 .path()
205 .join("cda.sock")
206 .to_string_lossy()
207 .to_string();
208
209 let webserver_config = cda_sovd::WebServerConfig::UnixSocket {
210 path: socket_path.clone(),
211 };
212
213 let (shutdown_tx, shutdown_signal) = shutdown_channel();
214
215 let (dynamic_router, webserver_join_handle) =
216 cda_sovd::launch_webserver(webserver_config, shutdown_signal.clone())
217 .await
218 .expect("Failed to launch webserver");
219
220 // Add custom routes directly. No vehicle routes or health checks are
221 // needed for this test since we talk to the raw socket directly instead
222 // of polling the health endpoint for readiness.
223 add_custom_routes(&dynamic_router).await;
224
225 assert_demo_endpoint_get_post(
226 || {
227 crate::util::http::send_unix_socket_request(
228 &socket_path,
229 "/test",
230 StatusCode::OK,
231 Method::GET,
232 None,
233 )
234 },
235 |body| {
236 let socket_path = socket_path.clone();
237 async move {
238 crate::util::http::send_unix_socket_request(
239 &socket_path,
240 "/test",
241 StatusCode::CREATED,
242 Method::POST,
243 Some(&body),
244 )
245 .await
246 }
247 .boxed()
248 },
249 )
250 .await;
251
252 shutdown_and_join(shutdown_tx, webserver_join_handle).await;
253}