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 aide::UseApi;
15use cda_plugin_security::Secured;
16use sovd_interfaces::error::ApiErrorResponse;
17
18use super::{
19 ApiError, DynamicPlugin, ErrorWrapper, FileManager, IntoResponse, Json, Query, Response, State,
20 StatusCode, TransformOperation, UdsEcu, WebserverEcuState, WithRejection,
21};
22use crate::sovd::{self, create_schema};
23
24pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
25 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
26 WithRejection(Query(query), _): WithRejection<
27 Query<sovd_interfaces::components::ecu::data::get::Query>,
28 ApiError,
29 >,
30 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
31) -> Response {
32 let schema = if query.include_schema {
33 Some(create_schema!(
34 sovd_interfaces::components::ecu::data::get::Response
35 ))
36 } else {
37 None
38 };
39 match uds
40 .get_components_data_info(&ecu_name, &(security_plugin as DynamicPlugin))
41 .await
42 {
43 Ok(mut items) => {
44 let sovd_component_data = sovd_interfaces::components::ecu::data::get::Response {
45 items: items
46 .drain(0..)
47 .map(crate::sovd::IntoSovd::into_sovd)
48 .collect(),
49 schema,
50 };
51 (StatusCode::OK, Json(sovd_component_data)).into_response()
52 }
53 Err(e) => ErrorWrapper {
54 error: e.into(),
55 include_schema: query.include_schema,
56 }
57 .into_response(),
58 }
59}
60
61pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
62 op.description("Get all ECU data.")
63 .response_with::<200, Json<sovd_interfaces::components::ecu::data::get::Response>, _>(
64 |res| {
65 res.description("Response with all data.").example(
66 sovd_interfaces::components::ecu::data::get::Response {
67 items: vec![sovd_interfaces::components::ecu::ComponentDataInfo {
68 category: "example_category".to_string(),
69 id: "example_id".to_string(),
70 name: "example_name".to_string(),
71 }],
72 schema: None,
73 },
74 )
75 },
76 )
77 .response_with::<400, Json<ApiErrorResponse<sovd::error::VendorErrorCode>>, _>(|res| {
78 res.description("Error while fetching data from ECU.")
79 .example(sovd_interfaces::error::ApiErrorResponse {
80 message: "Failed to fetch ECU data".to_string(),
81 error_code: sovd_interfaces::error::ErrorCode::VendorSpecific,
82 vendor_code: Some(sovd::error::VendorErrorCode::BadRequest),
83 parameters: None,
84 error_source: Some("ECU".to_string()),
85 schema: None,
86 })
87 })
88}
89
90pub(crate) mod diag_service {
91 use aide::{
92 UseApi,
93 transform::{TransformOperation, TransformParameter},
94 };
95 use axum::{
96 Json,
97 body::Bytes,
98 extract::{Path, Query, State},
99 response::{IntoResponse, Response},
100 };
101 use axum_extra::extract::WithRejection;
102 use cda_interfaces::{
103 DiagComm, DiagCommType, HashMap, HashMapExtensions, SchemaProvider, UdsEcu,
104 file_manager::FileManager,
105 };
106 use cda_plugin_security::Secured;
107 use http::{HeaderMap, StatusCode};
108
109 use crate::{
110 openapi,
111 sovd::{
112 IntoSovd, WebserverEcuState,
113 components::ecu::{DiagServicePathParam, data_request},
114 create_schema,
115 error::{ApiError, ErrorWrapper},
116 },
117 };
118
[docs]119 // [[ dimpl~sovd-api-component-data-sdgsd, GET /data/{service} SDG handler ]]
120 async fn get_sdgs_handler<T: UdsEcu + Clone>(
121 service: String,
122 ecu_name: &str,
123 gateway: &T,
124 include_schema: bool,
125 ) -> Response {
126 let service_ops = vec![
127 DiagComm {
128 name: service.clone(),
129 type_: DiagCommType::Data,
130 lookup_name: None,
131 subfunction_id: None,
132 },
133 DiagComm {
134 name: service.clone(),
135 type_: DiagCommType::Data,
136 lookup_name: None,
137 subfunction_id: None,
138 },
139 DiagComm {
140 name: service,
141 type_: DiagCommType::Data,
142 lookup_name: None,
143 subfunction_id: None,
144 },
145 ];
146 let schema = if include_schema {
147 Some(create_schema!(
148 sovd_interfaces::components::ecu::ServicesSdgs
149 ))
150 } else {
151 None
152 };
153 let mut resp = sovd_interfaces::components::ecu::ServicesSdgs {
154 items: HashMap::new(),
155 schema,
156 };
157 for service in service_ops {
158 match gateway.get_sdgs(ecu_name, Some(&service)).await {
159 Ok(sdgs) => {
160 if sdgs.is_empty() {
161 continue;
162 }
163 resp.items.insert(
164 format!("{}_{:?}", service.name, service.action()).to_lowercase(),
165 sovd_interfaces::components::ecu::ServiceSdgs {
166 sdgs: sdgs.into_sovd(),
167 },
168 );
169 }
170 Err(e) => {
171 return ErrorWrapper {
172 error: e.into(),
173 include_schema,
174 }
175 .into_response();
176 }
177 }
178 }
179 (StatusCode::OK, Json(resp)).into_response()
180 }
181
182 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Send + Sync + Clone, U: FileManager>(
183 headers: HeaderMap,
184 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
185 Path(DiagServicePathParam {
186 service: diag_service,
187 }): Path<DiagServicePathParam>,
188 WithRejection(Query(query), _): WithRejection<
189 Query<sovd_interfaces::components::ComponentQuery>,
190 ApiError,
191 >,
192 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
193 ) -> Response {
194 let include_schema = query.include_schema;
195 if query.include_sdgs {
196 get_sdgs_handler::<T>(diag_service, &ecu_name, &uds, include_schema).await
197 } else {
198 if diag_service.contains('/') {
199 return ErrorWrapper {
200 error: ApiError::BadRequest("Invalid path".to_owned()),
201 include_schema,
202 }
203 .into_response();
204 }
205 data_request::<T>(
206 DiagComm {
207 name: diag_service,
208 type_: DiagCommType::Data,
209 lookup_name: None,
210 subfunction_id: None,
211 },
212 &ecu_name,
213 &uds,
214 headers,
215 None,
216 security_plugin,
217 include_schema,
218 )
219 .await
220 }
221 }
222
223 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
224 op.description("Get a specific diagnostic service.")
225 .parameter("x-sovd2uds-includesdgs", |op: TransformParameter<bool>| {
226 op.description("Set to true to include sdgs.")
227 })
228 .with(openapi::ecu_service_response)
229 .with(openapi::error_forbidden)
230 .with(openapi::error_not_found)
231 .with(openapi::error_internal_server)
232 .with(openapi::error_conflict)
233 .with(openapi::error_bad_request)
234 .with(openapi::error_bad_gateway)
235 }
236
237 pub(crate) async fn put<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
238 headers: HeaderMap,
239 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
240 Path(DiagServicePathParam { service }): Path<DiagServicePathParam>,
241 WithRejection(Query(query), _): WithRejection<
242 Query<sovd_interfaces::components::ecu::data::service::put::Query>,
243 ApiError,
244 >,
245 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
246 body: Bytes,
247 ) -> Response {
248 let include_schema = query.include_schema;
249 if service.contains('/') {
250 return ErrorWrapper {
251 error: ApiError::BadRequest("Invalid path".to_owned()),
252 include_schema,
253 }
254 .into_response();
255 }
256 data_request::<T>(
257 DiagComm {
258 name: service.clone(),
259 type_: DiagCommType::Configurations,
260 lookup_name: None,
261 subfunction_id: None,
262 },
263 &ecu_name,
264 &uds,
265 headers,
266 Some(body),
267 security_plugin,
268 include_schema,
269 )
270 .await
271 }
272
273 pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
274 openapi::request_json_and_octet::<
275 sovd_interfaces::components::ecu::data::DataRequestPayload
276 >(op)
277 .description("Update data for a specific data service")
278 .with(openapi::ecu_service_response)
279 .with(openapi::error_forbidden)
280 .with(openapi::error_not_found)
281 .with(openapi::error_internal_server)
282 .with(openapi::error_conflict)
283 .with(openapi::error_bad_request)
284 .with(openapi::error_bad_gateway)
285 }
286
287 /// `GET /data/{service}/docs` - online capability description for a data service.
288 pub(crate) mod docs_endpoint {
289 use aide::{UseApi, openapi::OpenApi, transform::TransformOperation};
290 use axum::{
291 Json,
292 extract::{Path, State},
293 response::{IntoResponse as _, Response},
294 };
295 use cda_interfaces::{DynamicPlugin, SchemaProvider, UdsEcu, file_manager::FileManager};
296 use cda_plugin_security::Secured;
297
298 use crate::{
299 openapi,
300 sovd::{WebserverEcuState, docs, error::ApiError},
301 };
302
303 openapi::aide_helper::gen_path_param!(DataDocsPathParam service String);
304
305 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
306 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
307 Path(DataDocsPathParam { service }): Path<DataDocsPathParam>,
308 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
309 ) -> Response {
310 let security_plugin: DynamicPlugin = security_plugin;
311
312 // Verify the data service exists
313 let data_info = match uds
314 .get_components_data_info(&ecu_name, &security_plugin)
315 .await
316 {
317 Ok(info) => info,
318 Err(e) => return ApiError::from(e).into_response(),
319 };
320
321 if !data_info
322 .iter()
323 .any(|d| d.id.eq_ignore_ascii_case(&service))
324 {
325 return ApiError::NotFound(Some(format!("Data service '{service}' not found")))
326 .into_response();
327 }
328
329 docs::data::build_docs_response(&uds, &ecu_name, &service, "data").await
330 }
331
332 pub(crate) fn docs_transform(op: TransformOperation) -> TransformOperation {
333 op.description(
334 "Online capability description for a specific data service on this ECU component \
335 (ISO 17978-3 Section 7.5). Returns a self-contained OpenAPI specification \
336 describing the available methods (GET and optionally PUT) with their data types.",
337 )
338 .response_with::<200, Json<OpenApi>, _>(|res| {
339 res.description("Self-contained OpenAPI 3.1 specification for this data service.")
340 })
341 .with(openapi::error_not_found)
342 }
343
344 #[cfg(test)]
345 mod tests {
346 use aide::UseApi;
347 use axum::{extract::State, http::StatusCode};
348 use cda_interfaces::{
349 DiagServiceError, datatypes::ComponentDataInfo,
350 file_manager::mock::MockFileManager, mock::MockUdsEcu,
351 };
352 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
353
354 use super::*;
355 use crate::sovd::tests::create_test_webserver_state;
356
357 #[tokio::test]
358 async fn returns_200_with_openapi_doc_when_service_exists() {
359 let mut mock_uds = MockUdsEcu::new();
360 mock_uds
361 .expect_get_components_data_info()
362 .withf(|ecu, _| ecu == "TestECU")
363 .times(1)
364 .returning(|_, _| {
365 Ok(vec![ComponentDataInfo {
366 category: "sensor".to_owned(),
367 id: "EngineTemp".to_owned(),
368 name: "Engine Temperature".to_owned(),
369 }])
370 });
371
372 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
373 "TestECU".to_owned(),
374 mock_uds,
375 MockFileManager::new(),
376 );
377
378 let response = get::<MockUdsEcu, MockFileManager>(
379 UseApi(
380 Secured(Box::new(TestSecurityPlugin)),
381 std::marker::PhantomData,
382 ),
383 Path(DataDocsPathParam {
384 service: "EngineTemp".to_owned(),
385 }),
386 State(state),
387 )
388 .await;
389
390 assert_eq!(response.status(), StatusCode::OK);
391 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
392 .await
393 .unwrap();
394 let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
395 assert!(
396 doc.get("info").is_some(),
397 "Response should be a valid OpenAPI doc"
398 );
399 assert!(doc.get("paths").is_some(), "Response should contain paths");
400 }
401
402 #[tokio::test]
403 async fn returns_404_when_service_not_found() {
404 let mut mock_uds = MockUdsEcu::new();
405 mock_uds
406 .expect_get_components_data_info()
407 .returning(|_, _| {
408 Ok(vec![ComponentDataInfo {
409 category: "sensor".to_owned(),
410 id: "EngineTemp".to_owned(),
411 name: "Engine Temperature".to_owned(),
412 }])
413 });
414
415 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
416 "TestECU".to_owned(),
417 mock_uds,
418 MockFileManager::new(),
419 );
420
421 let response = get::<MockUdsEcu, MockFileManager>(
422 UseApi(
423 Secured(Box::new(TestSecurityPlugin)),
424 std::marker::PhantomData,
425 ),
426 Path(DataDocsPathParam {
427 service: "NonExistent".to_owned(),
428 }),
429 State(state),
430 )
431 .await;
432
433 assert_eq!(response.status(), StatusCode::NOT_FOUND);
434 }
435
436 #[tokio::test]
437 async fn returns_error_when_data_info_lookup_fails() {
438 let mut mock_uds = MockUdsEcu::new();
439 mock_uds
440 .expect_get_components_data_info()
441 .returning(|_, _| Err(DiagServiceError::NotFound("ECU not found".to_owned())));
442
443 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
444 "TestECU".to_owned(),
445 mock_uds,
446 MockFileManager::new(),
447 );
448
449 let response = get::<MockUdsEcu, MockFileManager>(
450 UseApi(
451 Secured(Box::new(TestSecurityPlugin)),
452 std::marker::PhantomData,
453 ),
454 Path(DataDocsPathParam {
455 service: "Anything".to_owned(),
456 }),
457 State(state),
458 )
459 .await;
460
461 assert_eq!(response.status(), StatusCode::NOT_FOUND);
462 }
463 }
464 }
465}