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}