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}