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, transform::TransformParameter};
15use cda_plugin_security::Secured;
16use serde_qs::axum::QsQuery;
17use sovd_interfaces::error::ApiErrorResponse;
18
19use super::{
20 ApiError, DynamicPlugin, ErrorWrapper, FileManager, IntoResponse, Json, Response, State,
21 StatusCode, TransformOperation, UdsEcu, WebserverEcuState, WithRejection,
22};
23use crate::sovd::{self, create_schema, locks::require_ecu_access};
24
25pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
26 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
27 WithRejection(QsQuery(query), _): WithRejection<
28 QsQuery<sovd_interfaces::components::ecu::data::get::Query>,
29 ApiError,
30 >,
31 State(WebserverEcuState {
32 ecu_name,
33 uds,
34 locks,
35 ..
36 }): State<WebserverEcuState<T, U>>,
37) -> Response {
38 require_ecu_access!(
39 read,
40 security_plugin,
41 &ecu_name,
42 &locks,
43 query.include_schema
44 );
45 let schema = if query.include_schema {
46 Some(create_schema!(
47 sovd_interfaces::components::ecu::data::get::Response
48 ))
49 } else {
50 None
51 };
52 match uds
53 .get_components_data_info(&ecu_name, &(security_plugin as DynamicPlugin))
54 .await
55 {
56 Ok(mut items) => {
57 if let Some(categories) = &query.categories {
58 items.retain(|item| {
59 categories
60 .iter()
61 .any(|category| category.eq_ignore_ascii_case(&item.category))
62 });
63 }
64 let sovd_component_data = sovd_interfaces::components::ecu::data::get::Response {
65 items: items
66 .drain(0..)
67 .map(crate::sovd::IntoSovd::into_sovd)
68 .collect(),
69 schema,
70 };
71 (StatusCode::OK, Json(sovd_component_data)).into_response()
72 }
73 Err(error) => ErrorWrapper {
74 error: error.into(),
75 include_schema: query.include_schema,
76 }
77 .into_response(),
78 }
79}
80
81pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
82 op.description("Get all ECU data.")
83 .parameter("categories", |op: TransformParameter<Vec<String>>| {
84 op.description(
85 "Optional list of categories. When present, only data resources whose category is \
86 one of the given values are returned. Multiple categories are passed by \
87 repeating the parameter (form style, explode=true), e.g. \
88 `?categories=identData&categories=currentData`.",
89 )
90 })
91 .response_with::<200, Json<sovd_interfaces::components::ecu::data::get::Response>, _>(
92 |res| {
93 res.description("Response with all data.").example(
94 sovd_interfaces::components::ecu::data::get::Response {
95 items: vec![sovd_interfaces::components::ecu::ComponentDataInfo {
96 category: "example_category".to_string(),
97 id: "example_id".to_string(),
98 name: "example_name".to_string(),
99 }],
100 schema: None,
101 },
102 )
103 },
104 )
105 .response_with::<400, Json<ApiErrorResponse<sovd::error::VendorErrorCode>>, _>(|res| {
106 res.description("Error while fetching data from ECU.")
107 .example(sovd_interfaces::error::ApiErrorResponse {
108 message: "Failed to fetch ECU data".to_string(),
109 error_code: sovd_interfaces::error::ErrorCode::VendorSpecific,
110 vendor_code: Some(sovd::error::VendorErrorCode::BadRequest),
111 parameters: None,
112 error_source: Some("ECU".to_string()),
113 schema: None,
114 })
115 })
116}
117
118#[cfg(test)]
119mod tests {
120 use aide::UseApi;
121 use axum::extract::State;
122 use axum_extra::extract::WithRejection;
123 use cda_interfaces::{
124 datatypes::ComponentDataInfo, file_manager::mock::MockFileManager, mock::MockUdsEcu,
125 };
126 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
127
128 use super::*;
129 use crate::sovd::tests::create_test_webserver_state;
130
131 fn three_items() -> Vec<ComponentDataInfo> {
132 vec![
133 ComponentDataInfo {
134 category: "identData".to_owned(),
135 id: "Foo".to_owned(),
136 name: "Foo".to_owned(),
137 },
138 ComponentDataInfo {
139 category: "currentData".to_owned(),
140 id: "Bar".to_owned(),
141 name: "Bar".to_owned(),
142 },
143 ComponentDataInfo {
144 category: "identData".to_owned(),
145 id: "Baz".to_owned(),
146 name: "Baz".to_owned(),
147 },
148 ]
149 }
150
151 async fn call_get(
152 mock_uds: MockUdsEcu,
153 query: sovd_interfaces::components::ecu::data::get::Query,
154 ) -> Response {
155 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
156 "TestECU".to_owned(),
157 mock_uds,
158 MockFileManager::new(),
159 );
160
161 get::<MockUdsEcu, MockFileManager>(
162 UseApi(
163 Secured(Box::new(TestSecurityPlugin)),
164 std::marker::PhantomData,
165 ),
166 WithRejection(QsQuery(query), std::marker::PhantomData),
167 State(state),
168 )
169 .await
170 }
171
172 /// Asserts the response is a `200 OK` and returns its parsed `items` array.
173 async fn ok_items(response: Response) -> Vec<serde_json::Value> {
174 assert_eq!(response.status(), StatusCode::OK);
175 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
176 .await
177 .unwrap();
178 let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
179 doc.get("items").unwrap().as_array().unwrap().clone()
180 }
181
182 #[tokio::test]
183 async fn returns_all_items_when_no_categories_filter() {
184 let mut mock_uds = MockUdsEcu::new();
185 mock_uds
186 .expect_get_components_data_info()
187 .returning(|_, _| Ok(three_items()));
188
189 let items = ok_items(
190 call_get(
191 mock_uds,
192 sovd_interfaces::components::ecu::data::get::Query {
193 include_schema: false,
194 categories: None,
195 },
196 )
197 .await,
198 )
199 .await;
200
201 assert_eq!(items.len(), 3);
202 }
203
204 #[tokio::test]
205 async fn filters_items_by_single_category() {
206 let mut mock_uds = MockUdsEcu::new();
207 mock_uds
208 .expect_get_components_data_info()
209 .returning(|_, _| Ok(three_items()));
210
211 let items = ok_items(
212 call_get(
213 mock_uds,
214 sovd_interfaces::components::ecu::data::get::Query {
215 include_schema: false,
216 categories: Some(vec!["identData".to_owned()]),
217 },
218 )
219 .await,
220 )
221 .await;
222
223 assert_eq!(items.len(), 2);
224 assert!(items.iter().all(|i| i["category"] == "identData"));
225 }
226
227 #[tokio::test]
228 async fn filters_items_by_multiple_categories() {
229 let mut mock_uds = MockUdsEcu::new();
230 mock_uds
231 .expect_get_components_data_info()
232 .returning(|_, _| Ok(three_items()));
233
234 let items = ok_items(
235 call_get(
236 mock_uds,
237 sovd_interfaces::components::ecu::data::get::Query {
238 include_schema: false,
239 categories: Some(vec!["identData".to_owned(), "currentData".to_owned()]),
240 },
241 )
242 .await,
243 )
244 .await;
245
246 assert_eq!(items.len(), 3);
247 }
248
249 #[tokio::test]
250 async fn returns_empty_when_category_does_not_match() {
251 let mut mock_uds = MockUdsEcu::new();
252 mock_uds
253 .expect_get_components_data_info()
254 .returning(|_, _| Ok(three_items()));
255
256 let items = ok_items(
257 call_get(
258 mock_uds,
259 sovd_interfaces::components::ecu::data::get::Query {
260 include_schema: false,
261 categories: Some(vec!["storedData".to_owned()]),
262 },
263 )
264 .await,
265 )
266 .await;
267
268 assert!(items.is_empty());
269 }
270
271 /// Request URIs (including their query string) are lowercased by CDA's request
272 /// normalization middleware (`rewrite_request_uri` in `cda-sovd::lib`) before reaching
273 /// this handler, so a category name requested as e.g. `identdata` must still match an
274 /// item whose `category` is the mixed-case `identData`.
275 #[tokio::test]
276 async fn filters_items_by_category_case_insensitively() {
277 let mut mock_uds = MockUdsEcu::new();
278 mock_uds
279 .expect_get_components_data_info()
280 .returning(|_, _| Ok(three_items()));
281
282 let items = ok_items(
283 call_get(
284 mock_uds,
285 sovd_interfaces::components::ecu::data::get::Query {
286 include_schema: false,
287 categories: Some(vec!["identdata".to_owned()]),
288 },
289 )
290 .await,
291 )
292 .await;
293
294 assert_eq!(items.len(), 2);
295 assert!(items.iter().all(|i| i["category"] == "identData"));
296 }
297}
298
299pub(crate) mod diag_service {
300 use aide::{
301 UseApi,
302 transform::{TransformOperation, TransformParameter},
303 };
304 use axum::{
305 Json,
306 body::Bytes,
307 extract::{Path, Query, State},
308 response::{IntoResponse, Response},
309 };
310 use axum_extra::extract::WithRejection;
311 use cda_interfaces::{
312 DiagComm, DiagCommType, HashMap, HashMapExtensions, SchemaProvider, UdsEcu,
313 file_manager::FileManager,
314 };
315 use cda_plugin_security::Secured;
316 use http::{HeaderMap, StatusCode};
317
318 use crate::{
319 openapi,
320 sovd::{
321 IntoSovd, WebserverEcuState,
322 components::ecu::{DiagServicePathParam, data_request},
323 create_schema,
324 error::{ApiError, ErrorWrapper},
325 locks::require_ecu_access,
326 },
327 };
328
[docs]329 // [[ dimpl~sovd-api-component-data-sdgsd, GET /data/{service} SDG handler ]]
330 async fn get_sdgs_handler<T: UdsEcu + Clone>(
331 service: String,
332 ecu_name: &str,
333 gateway: &T,
334 include_schema: bool,
335 ) -> Response {
336 let service_ops = vec![
337 DiagComm {
338 name: service.clone(),
339 type_: DiagCommType::Data,
340 lookup_name: None,
341 subfunction_id: None,
342 },
343 DiagComm {
344 name: service.clone(),
345 type_: DiagCommType::Data,
346 lookup_name: None,
347 subfunction_id: None,
348 },
349 DiagComm {
350 name: service,
351 type_: DiagCommType::Data,
352 lookup_name: None,
353 subfunction_id: None,
354 },
355 ];
356 let schema = if include_schema {
357 Some(create_schema!(
358 sovd_interfaces::components::ecu::ServicesSdgs
359 ))
360 } else {
361 None
362 };
363 let mut resp = sovd_interfaces::components::ecu::ServicesSdgs {
364 items: HashMap::new(),
365 schema,
366 };
367 for service in service_ops {
368 match gateway.get_sdgs(ecu_name, Some(&service)).await {
369 Ok(sdgs) => {
370 if sdgs.is_empty() {
371 continue;
372 }
373 resp.items.insert(
374 format!("{}_{:?}", service.name, service.action()).to_lowercase(),
375 sovd_interfaces::components::ecu::ServiceSdgs {
376 sdgs: sdgs.into_sovd(),
377 },
378 );
379 }
380 Err(e) => {
381 return ErrorWrapper {
382 error: e.into(),
383 include_schema,
384 }
385 .into_response();
386 }
387 }
388 }
389 (StatusCode::OK, Json(resp)).into_response()
390 }
391
392 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Send + Sync + Clone, U: FileManager>(
393 headers: HeaderMap,
394 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
395 Path(DiagServicePathParam {
396 service: diag_service,
397 }): Path<DiagServicePathParam>,
398 WithRejection(Query(query), _): WithRejection<
399 Query<sovd_interfaces::components::ComponentQuery>,
400 ApiError,
401 >,
402 State(WebserverEcuState {
403 ecu_name,
404 uds,
405 locks,
406 ..
407 }): State<WebserverEcuState<T, U>>,
408 ) -> Response {
409 let include_schema = query.include_schema;
410 if query.include_sdgs {
411 require_ecu_access!(read, security_plugin, &ecu_name, &locks, include_schema);
412 get_sdgs_handler::<T>(diag_service, &ecu_name, &uds, include_schema).await
413 } else {
414 if diag_service.contains('/') {
415 return ErrorWrapper {
416 error: ApiError::BadRequest("Invalid path".to_owned()),
417 include_schema,
418 }
419 .into_response();
420 }
421 data_request::<T>(
422 DiagComm {
423 name: diag_service,
424 type_: DiagCommType::Data,
425 lookup_name: None,
426 subfunction_id: None,
427 },
428 &ecu_name,
429 &uds,
430 headers,
431 None,
432 (security_plugin, &locks, false),
433 include_schema,
434 )
435 .await
436 }
437 }
438
439 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
440 op.description("Get a specific diagnostic service.")
441 .parameter("x-sovd2uds-includesdgs", |op: TransformParameter<bool>| {
442 op.description("Set to true to include sdgs.")
443 })
444 .with(openapi::ecu_service_response)
445 .with(openapi::error_forbidden)
446 .with(openapi::error_not_found)
447 .with(openapi::error_internal_server)
448 .with(openapi::error_conflict)
449 .with(openapi::error_bad_request)
450 .with(openapi::error_bad_gateway)
451 }
452
453 pub(crate) async fn put<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
454 headers: HeaderMap,
455 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
456 Path(DiagServicePathParam { service }): Path<DiagServicePathParam>,
457 WithRejection(Query(query), _): WithRejection<
458 Query<sovd_interfaces::components::ecu::data::service::put::Query>,
459 ApiError,
460 >,
461 State(WebserverEcuState {
462 ecu_name,
463 uds,
464 locks,
465 ..
466 }): State<WebserverEcuState<T, U>>,
467 body: Bytes,
468 ) -> Response {
469 let include_schema = query.include_schema;
470 if service.contains('/') {
471 return ErrorWrapper {
472 error: ApiError::BadRequest("Invalid path".to_owned()),
473 include_schema,
474 }
475 .into_response();
476 }
477 data_request::<T>(
478 DiagComm {
479 name: service.clone(),
480 type_: DiagCommType::Configurations,
481 lookup_name: None,
482 subfunction_id: None,
483 },
484 &ecu_name,
485 &uds,
486 headers,
487 Some(body),
488 (security_plugin, &locks, true),
489 include_schema,
490 )
491 .await
492 }
493
494 pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
495 openapi::request_json_and_octet::<
496 sovd_interfaces::components::ecu::data::DataRequestPayload
497 >(op)
498 .description("Update data for a specific data service")
499 .with(openapi::ecu_service_response)
500 .with(openapi::error_forbidden)
501 .with(openapi::error_not_found)
502 .with(openapi::error_internal_server)
503 .with(openapi::error_conflict)
504 .with(openapi::error_bad_request)
505 .with(openapi::error_bad_gateway)
506 }
507
508 /// `GET /data/{service}/docs` - online capability description for a data service.
509 pub(crate) mod docs_endpoint {
510 use aide::{UseApi, openapi::OpenApi, transform::TransformOperation};
511 use axum::{
512 Json,
513 extract::{Path, State},
514 response::{IntoResponse as _, Response},
515 };
516 use cda_interfaces::{DynamicPlugin, SchemaProvider, UdsEcu, file_manager::FileManager};
517 use cda_plugin_security::Secured;
518
519 use crate::{
520 openapi,
521 sovd::{WebserverEcuState, docs, error::ApiError, locks::require_ecu_access},
522 };
523
524 openapi::aide_helper::gen_path_param!(DataDocsPathParam service String);
525
526 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
527 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
528 Path(DataDocsPathParam { service }): Path<DataDocsPathParam>,
529 State(WebserverEcuState {
530 ecu_name,
531 uds,
532 locks,
533 ..
534 }): State<WebserverEcuState<T, U>>,
535 ) -> Response {
536 require_ecu_access!(read, security_plugin, &ecu_name, &locks, false);
537 let security_plugin: DynamicPlugin = security_plugin;
538
539 // Verify the data service exists
540 let data_info = match uds
541 .get_components_data_info(&ecu_name, &security_plugin)
542 .await
543 {
544 Ok(info) => info,
545 Err(e) => return ApiError::from(e).into_response(),
546 };
547
548 if !data_info
549 .iter()
550 .any(|d| d.id.eq_ignore_ascii_case(&service))
551 {
552 return ApiError::NotFound(Some(format!("Data service '{service}' not found")))
553 .into_response();
554 }
555
556 docs::data::build_docs_response(&uds, &ecu_name, &service, "data").await
557 }
558
559 pub(crate) fn docs_transform(op: TransformOperation) -> TransformOperation {
560 op.description(
561 "Online capability description for a specific data service on this ECU component \
562 (ISO 17978-3 Section 7.5). Returns a self-contained OpenAPI specification \
563 describing the available methods (GET and optionally PUT) with their data types.",
564 )
565 .response_with::<200, Json<OpenApi>, _>(|res| {
566 res.description("Self-contained OpenAPI 3.1 specification for this data service.")
567 })
568 .with(openapi::error_not_found)
569 }
570
571 #[cfg(test)]
572 mod tests {
573 use aide::UseApi;
574 use axum::{extract::State, http::StatusCode};
575 use cda_interfaces::{
576 DiagServiceError,
577 datatypes::ComponentDataInfo,
578 file_manager::mock::MockFileManager,
579 mock::{MockUdsEcu, mock_ecu_state_online_variant_detected},
580 };
581 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
582
583 use super::*;
584 use crate::sovd::tests::create_test_webserver_state;
585
586 #[tokio::test]
587 async fn returns_200_with_openapi_doc_when_service_exists() {
588 let mut mock_uds = MockUdsEcu::new();
589 mock_uds
590 .expect_get_ecu_state()
591 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
592 mock_uds
593 .expect_get_components_data_info()
594 .withf(|ecu, _| ecu == "TestECU")
595 .times(1)
596 .returning(|_, _| {
597 Ok(vec![ComponentDataInfo {
598 category: "sensor".to_owned(),
599 id: "EngineTemp".to_owned(),
600 name: "Engine Temperature".to_owned(),
601 }])
602 });
603
604 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
605 "TestECU".to_owned(),
606 mock_uds,
607 MockFileManager::new(),
608 );
609
610 let response = get::<MockUdsEcu, MockFileManager>(
611 UseApi(
612 Secured(Box::new(TestSecurityPlugin)),
613 std::marker::PhantomData,
614 ),
615 Path(DataDocsPathParam {
616 service: "EngineTemp".to_owned(),
617 }),
618 State(state),
619 )
620 .await;
621
622 assert_eq!(response.status(), StatusCode::OK);
623 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
624 .await
625 .unwrap();
626 let doc: serde_json::Value = serde_json::from_slice(&body).unwrap();
627 assert!(
628 doc.get("info").is_some(),
629 "Response should be a valid OpenAPI doc"
630 );
631 assert!(doc.get("paths").is_some(), "Response should contain paths");
632 }
633
634 #[tokio::test]
635 async fn returns_404_when_service_not_found() {
636 let mut mock_uds = MockUdsEcu::new();
637 mock_uds
638 .expect_get_ecu_state()
639 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
640 mock_uds
641 .expect_get_components_data_info()
642 .returning(|_, _| {
643 Ok(vec![ComponentDataInfo {
644 category: "sensor".to_owned(),
645 id: "EngineTemp".to_owned(),
646 name: "Engine Temperature".to_owned(),
647 }])
648 });
649
650 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
651 "TestECU".to_owned(),
652 mock_uds,
653 MockFileManager::new(),
654 );
655
656 let response = get::<MockUdsEcu, MockFileManager>(
657 UseApi(
658 Secured(Box::new(TestSecurityPlugin)),
659 std::marker::PhantomData,
660 ),
661 Path(DataDocsPathParam {
662 service: "NonExistent".to_owned(),
663 }),
664 State(state),
665 )
666 .await;
667
668 assert_eq!(response.status(), StatusCode::NOT_FOUND);
669 }
670
671 #[tokio::test]
672 async fn returns_error_when_data_info_lookup_fails() {
673 let mut mock_uds = MockUdsEcu::new();
674 mock_uds
675 .expect_get_ecu_state()
676 .returning(|_| Ok(mock_ecu_state_online_variant_detected()));
677 mock_uds
678 .expect_get_components_data_info()
679 .returning(|_, _| Err(DiagServiceError::NotFound("ECU not found".to_owned())));
680
681 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
682 "TestECU".to_owned(),
683 mock_uds,
684 MockFileManager::new(),
685 );
686
687 let response = get::<MockUdsEcu, MockFileManager>(
688 UseApi(
689 Secured(Box::new(TestSecurityPlugin)),
690 std::marker::PhantomData,
691 ),
692 Path(DataDocsPathParam {
693 service: "Anything".to_owned(),
694 }),
695 State(state),
696 )
697 .await;
698
699 assert_eq!(response.status(), StatusCode::NOT_FOUND);
700 }
701 }
702 }
703}