1/*
  2 * SPDX-FileCopyrightText: 2026 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::{axum::IntoApiResponse, transform::TransformOperation};
 15use axum::{
 16    Json,
 17    body::Bytes,
 18    extract::{Query, State},
 19    response::{IntoResponse, Response},
 20};
 21use axum_extra::extract::WithRejection;
 22use cda_interfaces::{
 23    DiagComm, DynamicPlugin, SchemaProvider, UdsEcu,
 24    diagservices::{DiagServiceJsonResponse, DiagServiceResponseType},
 25    file_manager::FileManager,
 26};
 27use cda_plugin_security::SecurityPlugin;
 28use http::{HeaderMap, StatusCode};
 29
 30use crate::{
 31    openapi,
 32    sovd::{
 33        IntoSovd, WebserverEcuState,
 34        components::get_content_type_and_accept,
 35        create_response_schema, create_schema,
 36        error::{ApiError, ErrorWrapper, api_error_from_diag_response},
 37        field_parse_errors_to_json, get_payload_data,
 38        locks::{Locks, require_ecu_access},
 39    },
 40};
 41
 42pub(crate) mod configurations;
 43pub(crate) mod data;
 44pub(crate) mod data_categories;
 45pub(crate) mod faults;
 46pub(crate) mod genericservice;
 47pub(crate) mod modes;
 48pub(crate) mod operations;
 49pub(crate) mod x_single_ecu_jobs;
 50pub(crate) mod x_sovd2uds_bulk_data;
 51pub(crate) mod x_sovd2uds_download;
 52
[docs] 53// [[ dimpl~sovd-api-component-sdgsd, GET /components/{ecu} SDG handler ]]
 54pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
 55    State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
 56    WithRejection(Query(query), _): WithRejection<
 57        Query<sovd_interfaces::components::ComponentQuery>,
 58        ApiError,
 59    >,
 60) -> impl IntoApiResponse {
 61    let include_schema = query.include_schema;
 62    let base_path = format!("http://localhost:20002/vehicle/v15/components/{ecu_name}");
 63    let status = match uds.get_ecu_state(&ecu_name).await {
 64        Ok(v) => v,
 65        Err(e) => {
 66            return ErrorWrapper {
 67                error: e.into(),
 68                include_schema,
 69            }
 70            .into_response();
 71        }
 72    };
 73    let logical_address = match uds.get_logical_address(&ecu_name).await {
 74        Ok(v) => v,
 75        Err(e) => {
 76            return ErrorWrapper {
 77                error: e.into(),
 78                include_schema,
 79            }
 80            .into_response();
 81        }
 82    };
 83
 84    let variant = sovd_interfaces::components::ecu::Variant {
 85        name: status.name().unwrap_or("Unknown").to_owned(),
 86        is_base_variant: status.is_base_variant(),
 87        state: status.into_sovd(),
 88        logical_address: format!("0x{logical_address:02x}"),
 89    };
 90
 91    let sdgs = if query.include_sdgs {
 92        match uds.get_sdgs(&ecu_name, None).await {
 93            Ok(v) => Some(
 94                v.into_iter()
 95                    .map(super::super::IntoSovd::into_sovd)
 96                    .collect(),
 97            ),
 98            Err(e) => {
 99                return ErrorWrapper {
100                    error: e.into(),
101                    include_schema,
102                }
103                .into_response();
104            }
105        }
106    } else {
107        None
108    };
109
110    let schema = if include_schema {
111        Some(create_schema!(
112            sovd_interfaces::components::ecu::get::Response
113        ))
114    } else {
115        None
116    };
117
118    (
119        StatusCode::OK,
120        Json(sovd_interfaces::components::ecu::get::Response {
121            id: ecu_name.to_lowercase(),
122            name: ecu_name.clone(),
123            variant,
124            locks: format!("{base_path}/locks"),
125            operations: format!("{base_path}/operations"),
126            configurations: format!("{base_path}/configurations"),
127            data: format!("{base_path}/data"),
128            sdgs,
129            single_ecu_jobs: format!("{base_path}/x-single-ecu-jobs"),
130            faults: format!("{base_path}/faults"),
131            modes: format!("{base_path}/modes"),
132            schema,
133        }),
134    )
135        .into_response()
136}
137
138pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
139    op.description("Get ECU details")
140        .response_with::<200, Json<sovd_interfaces::components::ecu::Ecu>, _>(|res| {
141            res.example(sovd_interfaces::components::ecu::Ecu {
142                id: "my_ecu".to_string(),
143                name: "My ECU".to_string(),
144                variant: sovd_interfaces::components::ecu::Variant {
145                    name: "Variant Name".to_owned(),
146                    is_base_variant: false,
147                    state: sovd_interfaces::components::ecu::State::Online,
148                    logical_address: "0x42".to_owned(),
149                },
150                locks: "http://localhost:20002/vehicle/v15/components/my_ecu/locks".to_string(),
151                operations: "http://localhost:20002/vehicle/v15/components/my_ecu/operations"
152                    .to_string(),
153                data: "http://localhost:20002/vehicle/v15/components/my_ecu/data".to_string(),
154                configurations:
155                    "http://localhost:20002/vehicle/v15/components/my_ecu/configurations"
156                        .to_string(),
157                sdgs: None,
158                single_ecu_jobs:
159                    "http://localhost:20002/vehicle/v15/components/my_ecu/x-single-ecu-jobs"
160                        .to_string(),
161                faults: "http://localhost:20002/vehicle/v15/components/my_ecu/faults".to_string(),
162                modes: "http://localhost:20002/vehicle/v15/components/my_ecu/modes".to_string(),
163                schema: None,
164            })
165            .description("Response with ECU information (i.e. detected variant) and service URLs")
166        })
167}
168
169pub(crate) async fn post<T: UdsEcu + Clone, U: FileManager>(
170    State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
171) -> Response {
172    update(&ecu_name, uds).await
173}
174
[docs]175// [[ dimpl~sovd-api-ecu-variant-detection, PUT endpoint for ECU variant detection ]]
176//
177// Handles PUT requests on /components/{ecuName} to trigger variant detection.
178// Delegates to the UDS layer which sends diagnostic requests to the ECU and
179// evaluates the responses against known variant patterns. Returns 201 on
180// success or an error response if detection fails.
181pub(crate) async fn put<T: UdsEcu + Clone, U: FileManager>(
182    State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
183) -> Response {
184    update(&ecu_name, uds).await
185}
186
187pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
188    op.description("Trigger ECU variant detection")
189        .response_with::<201, (), _>(|res| res.description("ECU variant detection triggered."))
190}
191
192async fn update<T: UdsEcu + Clone>(ecu_name: &str, uds: T) -> Response {
193    match uds.detect_variant(ecu_name).await {
194        Ok(()) => (StatusCode::CREATED, ()).into_response(),
195        Err(e) => ErrorWrapper {
196            error: e.into(),
197            include_schema: false,
198        }
199        .into_response(),
200    }
201}
202
203impl IntoSovd for cda_interfaces::datatypes::ComplexComParamValue {
204    type SovdType = sovd_interfaces::components::ecu::operations::comparams::ComplexComParamValue;
205
206    fn into_sovd(self) -> Self::SovdType {
207        self.into_iter()
208            .map(|(key, value)| (key, value.into_sovd()))
209            .collect()
210    }
211}
212
213impl IntoSovd for cda_interfaces::datatypes::ComParamValue {
214    type SovdType = sovd_interfaces::components::ecu::operations::comparams::ComParamValue;
215
216    fn into_sovd(self) -> Self::SovdType {
217        match self {
218            Self::Simple(simple) => Self::SovdType::Simple(simple.into_sovd()),
219            Self::Complex(complex) => Self::SovdType::Complex(complex.into_sovd()),
220        }
221    }
222}
223
224impl IntoSovd for cda_interfaces::datatypes::ComParamSimpleValue {
225    type SovdType = sovd_interfaces::components::ecu::operations::comparams::ComParamSimpleValue;
226
227    fn into_sovd(self) -> Self::SovdType {
228        Self::SovdType {
229            value: self.value.clone(),
230            unit: self.unit.map(|u| {
231                sovd_interfaces::components::ecu::operations::comparams::Unit {
232                    factor_to_si_unit: u.factor_to_si_unit,
233                    offset_to_si_unit: u.offset_to_si_unit,
234                }
235            }),
236        }
237    }
238}
239
240openapi::aide_helper::gen_path_param!(DiagServicePathParam service String);
241
242/// Parsed and validated inputs extracted from request headers and body.
243#[derive(Debug)]
244struct ParsedRequest {
245    data: Option<cda_interfaces::diagservices::UdsPayloadData>,
246    map_to_json: bool,
247}
248
249/// Parses and validates the HTTP headers and optional request body for a data
250/// service request. Returns a [`ParsedRequest`] on success or an [`ApiError`]
251/// describing the first validation failure.
252fn parse_data_request(
253    headers: &HeaderMap,
254    body: Option<Bytes>,
255    include_schema: bool,
256) -> Result<ParsedRequest, ApiError> {
257    let (content_type, accept) = get_content_type_and_accept(headers)?;
258
259    let data = if let Some(body) = body {
260        get_payload_data::<sovd_interfaces::components::ecu::data::DataRequestPayload>(
261            content_type.as_ref(),
262            headers,
263            &body,
264        )?
265    } else {
266        None
267    };
268
269    let map_to_json = match (accept.type_(), accept.subtype()) {
270        (mime::APPLICATION, mime::JSON) => true,
271        (mime::APPLICATION, mime::OCTET_STREAM) => false,
272        unsupported => {
273            return Err(ApiError::BadRequest(format!(
274                "Unsupported Accept: {unsupported:?}"
275            )));
276        }
277    };
278
279    if !map_to_json && include_schema {
280        return Err(ApiError::BadRequest(
281            "Cannot use include-schema with non-JSON response".to_string(),
282        ));
283    }
284
285    Ok(ParsedRequest { data, map_to_json })
286}
287
288/// Fetches the optional response schema and sends the UDS diagnostic request.
289/// Returns the raw response and optional schema on success, or an [`ApiError`] on failure.
290async fn execute_uds_data_request<T: UdsEcu + SchemaProvider + Clone>(
291    gateway: &T,
292    ecu_name: &str,
293    service: &DiagComm,
294    security_plugin: Box<dyn SecurityPlugin>,
295    data: Option<cda_interfaces::diagservices::UdsPayloadData>,
296    map_to_json: bool,
297    include_schema: bool,
298) -> Result<(T::Response, Option<schemars::Schema>), ApiError> {
299    let schema = if include_schema {
300        let data_schema = gateway
301            .schema_for_responses(ecu_name, service)
302            .await
303            .map(cda_interfaces::SchemaDescription::into_schema)
304            .map_err(Into::into)
305            .map_err(|e: ApiError| e)?;
306        Some(create_response_schema!(
307            sovd_interfaces::ObjectDataItem<VendorErrorCode>,
308            "data",
309            data_schema
310        ))
311    } else {
312        None
313    };
314
315    let response = gateway
316        .send(
317            ecu_name,
318            service.clone(),
319            &(security_plugin as DynamicPlugin),
320            data,
321            map_to_json,
322        )
323        .await
324        .map_err(Into::into)
325        .map_err(|e: ApiError| e)?;
326
327    Ok((response, schema))
328}
329
330/// Converts a completed [`DiagServiceResponse`] into an HTTP [`Response`],
331/// honoring the `map_to_json` flag and the optional inline schema.
332fn format_data_response<R: cda_interfaces::diagservices::DiagServiceResponse>(
333    response: R,
334    service: &DiagComm,
335    map_to_json: bool,
336    include_schema: bool,
337    schema: Option<schemars::Schema>,
338) -> Response {
339    if let DiagServiceResponseType::Negative = response.response_type() {
340        return api_error_from_diag_response(&response, include_schema).into_response();
341    }
342
343    if response.is_empty() {
344        return StatusCode::NO_CONTENT.into_response();
345    }
346
347    if map_to_json {
348        let (mapped_data, errors) = match response.into_json() {
349            Ok(DiagServiceJsonResponse {
350                data: serde_json::Value::Object(mapped_data),
351                errors,
352            }) => (mapped_data, errors),
353            Ok(DiagServiceJsonResponse {
354                data: serde_json::Value::Null,
355                errors,
356            }) => {
357                if errors.is_empty() {
358                    return StatusCode::NO_CONTENT.into_response();
359                }
360                (serde_json::Map::new(), errors)
361            }
362            Ok(v) => {
363                return ErrorWrapper {
364                    error: ApiError::InternalServerError(Some(format!(
365                        "Expected JSON object but got: {}",
366                        v.data
367                    ))),
368                    include_schema,
369                }
370                .into_response();
371            }
372            Err(e) => {
373                return ErrorWrapper {
374                    error: ApiError::InternalServerError(Some(format!("{e:?}"))),
375                    include_schema,
376                }
377                .into_response();
378            }
379        };
380        (
381            StatusCode::OK,
382            Json(sovd_interfaces::ObjectDataItem {
383                id: service.name.to_lowercase(),
384                data: mapped_data,
385                errors: field_parse_errors_to_json(errors, "data"),
386                schema,
387            }),
388        )
389            .into_response()
390    } else {
391        let data = response.get_raw().to_vec();
392        (StatusCode::OK, Bytes::from_owner(data)).into_response()
393    }
394}
395
396/// Orchestrates [`parse_data_request`], [`execute_uds_data_request`], and
397/// [`format_data_response`] to handle a complete ECU data service request.
398async fn data_request<T: UdsEcu + SchemaProvider + Clone>(
399    service: DiagComm,
400    ecu_name: &str,
401    gateway: &T,
402    headers: HeaderMap,
403    body: Option<Bytes>,
404    authorization: (Box<dyn SecurityPlugin>, &Locks, bool),
405    include_schema: bool,
406) -> Response {
407    let (security_plugin, locks, is_write) = authorization;
408    if is_write {
409        require_ecu_access!(write, security_plugin, ecu_name, locks, include_schema);
410    } else {
411        require_ecu_access!(read, security_plugin, ecu_name, locks, include_schema);
412    }
413
414    let parsed = match parse_data_request(&headers, body, include_schema) {
415        Ok(v) => v,
416        Err(e) => {
417            return ErrorWrapper {
418                error: e,
419                include_schema,
420            }
421            .into_response();
422        }
423    };
424
425    let (response, schema) = match execute_uds_data_request(
426        gateway,
427        ecu_name,
428        &service,
429        security_plugin,
430        parsed.data,
431        parsed.map_to_json,
432        include_schema,
433    )
434    .await
435    {
436        Ok(v) => v,
437        Err(e) => {
438            return ErrorWrapper {
439                error: e,
440                include_schema,
441            }
442            .into_response();
443        }
444    };
445
446    format_data_response(
447        response,
448        &service,
449        parsed.map_to_json,
450        include_schema,
451        schema,
452    )
453}
454
455#[cfg(test)]
456mod tests {
457    use bytes::Bytes;
458    use cda_interfaces::{
459        DataParseError, DiagComm, DiagCommType,
460        diagservices::{
461            DiagServiceJsonResponse, DiagServiceResponseType, FieldParseError,
462            mock::MockDiagServiceResponse,
463        },
464    };
465    use http::{HeaderMap, HeaderValue, StatusCode, header};
466
467    use super::{format_data_response, parse_data_request};
468    use crate::sovd::error::ApiError;
469
470    async fn body_bytes(response: axum::response::Response) -> Bytes {
471        axum::body::to_bytes(response.into_body(), usize::MAX)
472            .await
473            .unwrap()
474    }
475
476    fn make_field_parse_error(path: &str, value: &str, details: &str) -> FieldParseError {
477        FieldParseError {
478            path: path.to_string(),
479            error: DataParseError {
480                value: value.to_string(),
481                details: details.to_string(),
482            },
483        }
484    }
485
486    #[test]
487    fn negative_response_returns_bad_gateway() {
488        let mut mock = MockDiagServiceResponse::new();
489        mock.expect_response_type()
490            .returning(|| DiagServiceResponseType::Negative);
491        mock.expect_as_nrc().returning(|| {
492            Ok(cda_interfaces::diagservices::MappedNRC {
493                code: Some(0x22),
494                description: Some("conditionsNotCorrect".to_string()),
495                sid: Some(0x22),
496            })
497        });
498
499        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
500        let response = format_data_response(mock, &service, true, false, None);
501
502        assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
503    }
504
505    #[tokio::test]
506    async fn empty_positive_response_returns_no_content() {
507        let mut mock = MockDiagServiceResponse::new();
508        mock.expect_response_type()
509            .returning(|| DiagServiceResponseType::Positive);
510        mock.expect_is_empty().returning(|| true);
511
512        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
513        let response = format_data_response(mock, &service, true, false, None);
514
515        assert_eq!(response.status(), StatusCode::NO_CONTENT);
516        assert!(body_bytes(response).await.is_empty());
517    }
518
519    #[tokio::test]
520    async fn map_to_json_with_object_data_returns_200_with_json_body() {
521        let mut mock = MockDiagServiceResponse::new();
522        mock.expect_response_type()
523            .returning(|| DiagServiceResponseType::Positive);
524        mock.expect_is_empty().returning(|| false);
525        mock.expect_into_json().returning(|| {
526            let mut map = serde_json::Map::new();
527            map.insert("rpm".to_string(), serde_json::json!(1200));
528            Ok(DiagServiceJsonResponse {
529                data: serde_json::Value::Object(map),
530                errors: vec![],
531            })
532        });
533
534        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
535        let response = format_data_response(mock, &service, true, false, None);
536
537        assert_eq!(response.status(), StatusCode::OK);
538        let body = body_bytes(response).await;
539        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
540        assert_eq!(json.get("id").and_then(|v| v.as_str()), Some("readrpm"));
541        assert_eq!(
542            json.get("data")
543                .and_then(|v| v.get("rpm"))
544                .and_then(serde_json::Value::as_u64),
545            Some(1200)
546        );
547    }
548
549    #[tokio::test]
550    async fn map_to_json_null_data_with_no_errors_returns_no_content() {
551        let mut mock = MockDiagServiceResponse::new();
552        mock.expect_response_type()
553            .returning(|| DiagServiceResponseType::Positive);
554        mock.expect_is_empty().returning(|| false);
555        mock.expect_into_json().returning(|| {
556            Ok(DiagServiceJsonResponse {
557                data: serde_json::Value::Null,
558                errors: vec![],
559            })
560        });
561
562        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
563        let response = format_data_response(mock, &service, true, false, None);
564
565        assert_eq!(response.status(), StatusCode::NO_CONTENT);
566    }
567
568    #[tokio::test]
569    async fn map_to_json_non_object_data_returns_500() {
570        let mut mock = MockDiagServiceResponse::new();
571        mock.expect_response_type()
572            .returning(|| DiagServiceResponseType::Positive);
573        mock.expect_is_empty().returning(|| false);
574        mock.expect_into_json().returning(|| {
575            Ok(DiagServiceJsonResponse {
576                data: serde_json::json!([1, 2, 3]),
577                errors: vec![],
578            })
579        });
580
581        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
582        let response = format_data_response(mock, &service, true, false, None);
583
584        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
585    }
586
587    #[tokio::test]
588    async fn map_to_json_into_json_error_returns_500() {
589        let mut mock = MockDiagServiceResponse::new();
590        mock.expect_response_type()
591            .returning(|| DiagServiceResponseType::Positive);
592        mock.expect_is_empty().returning(|| false);
593        mock.expect_into_json().returning(|| {
594            Err(cda_interfaces::DiagServiceError::InvalidRequest(
595                "test".into(),
596            ))
597        });
598
599        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
600        let response = format_data_response(mock, &service, true, false, None);
601
602        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
603    }
604
605    #[tokio::test]
606    async fn raw_response_returns_200_with_binary_body() {
607        let mut mock = MockDiagServiceResponse::new();
608        mock.expect_response_type()
609            .returning(|| DiagServiceResponseType::Positive);
610        mock.expect_is_empty().returning(|| false);
611        mock.expect_get_raw()
612            .return_const(vec![0xDEu8, 0xAD, 0xBE, 0xEF]);
613
614        let service = DiagComm::new("ReadRaw", DiagCommType::Data);
615        let response = format_data_response(mock, &service, false, false, None);
616
617        assert_eq!(response.status(), StatusCode::OK);
618        let body = body_bytes(response).await;
619        assert_eq!(body.as_ref(), &[0xDEu8, 0xAD, 0xBE, 0xEF]);
620    }
621
622    #[tokio::test]
623    async fn map_to_json_includes_schema_when_provided() {
624        let mut mock = MockDiagServiceResponse::new();
625        mock.expect_response_type()
626            .returning(|| DiagServiceResponseType::Positive);
627        mock.expect_is_empty().returning(|| false);
628        mock.expect_into_json().returning(|| {
629            Ok(DiagServiceJsonResponse {
630                data: serde_json::Value::Object(serde_json::Map::new()),
631                errors: vec![],
632            })
633        });
634
635        let schema: schemars::Schema =
636            serde_json::from_value(serde_json::json!({"type": "object"})).unwrap();
637
638        let service = DiagComm::new("ReadRPM", DiagCommType::Data);
639        let response = format_data_response(mock, &service, true, true, Some(schema));
640
641        assert_eq!(response.status(), StatusCode::OK);
642        // schema is present in the body because ObjectDataItem serialises it when Some
643        let body = body_bytes(response).await;
644        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
645        assert!(
646            json.get("schema").is_some(),
647            "expected schema field in body"
648        );
649    }
650
651    #[tokio::test]
652    async fn map_to_json_with_field_parse_errors_includes_errors_in_body() {
653        let mut mock = MockDiagServiceResponse::new();
654        mock.expect_response_type()
655            .returning(|| DiagServiceResponseType::Positive);
656        mock.expect_is_empty().returning(|| false);
657        mock.expect_into_json().returning(|| {
658            let mut map = serde_json::Map::new();
659            map.insert("voltage".to_string(), serde_json::json!(12.0));
660            Ok(DiagServiceJsonResponse {
661                data: serde_json::Value::Object(map),
662                errors: vec![
663                    make_field_parse_error("/current", "0xFF", "unknown encoding"),
664                    make_field_parse_error("/temperature", "0xAB", "out of range"),
665                ],
666            })
667        });
668
669        let service = DiagComm::new("ReadBattery", DiagCommType::Data);
670        let response = format_data_response(mock, &service, true, false, None);
671
672        assert_eq!(response.status(), StatusCode::OK);
673        let body = body_bytes(response).await;
674        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
675        let errors = json
676            .get("errors")
677            .and_then(|v| v.as_array())
678            .expect("expected errors array");
679        assert_eq!(errors.len(), 2);
680        // paths should be prefixed with /data
681        let paths: Vec<&str> = errors.iter().map(|e| e["path"].as_str().unwrap()).collect();
682        assert!(paths.iter().all(|p| p.starts_with("/data")));
683    }
684
685    fn headers_with(pairs: &[(&str, &str)]) -> HeaderMap {
686        let mut map = HeaderMap::new();
687        for (name, value) in pairs {
688            map.insert(
689                header::HeaderName::from_bytes(name.as_bytes()).unwrap(),
690                HeaderValue::from_str(value).unwrap(),
691            );
692        }
693        map
694    }
695
696    #[test]
697    fn no_headers_no_body_defaults_to_json() {
698        // No Content-Type, no Accept -> should default to application/json
699        let headers = HeaderMap::new();
700        let result = parse_data_request(&headers, None, false);
701
702        let parsed = result.expect("should succeed");
703        assert!(parsed.map_to_json, "expected map_to_json=true");
704        assert!(parsed.data.is_none(), "expected no data");
705    }
706
707    #[test]
708    fn accept_json_no_body_sets_map_to_json_true() {
709        let headers = headers_with(&[("accept", "application/json")]);
710        let result = parse_data_request(&headers, None, false);
711
712        let parsed = result.expect("should succeed");
713        assert!(parsed.map_to_json);
714        assert!(parsed.data.is_none());
715    }
716
717    #[test]
718    fn accept_octet_stream_no_body_sets_map_to_json_false() {
719        let headers = headers_with(&[("accept", "application/octet-stream")]);
720        let result = parse_data_request(&headers, None, false);
721
722        let parsed = result.expect("should succeed");
723        assert!(!parsed.map_to_json);
724        assert!(parsed.data.is_none());
725    }
726
727    #[test]
728    fn accept_wildcard_falls_back_to_json_when_no_content_type() {
729        // Accept: */* with no Content-Type -> content_type is None, accept_header
730        // collapses to APPLICATION_JSON per get_content_type_and_accept logic.
731        let headers = headers_with(&[("accept", "*/*")]);
732        let result = parse_data_request(&headers, None, false);
733
734        let parsed = result.expect("should succeed");
735        assert!(parsed.map_to_json);
736    }
737
738    #[test]
739    fn unsupported_accept_returns_bad_request() {
740        let headers = headers_with(&[("accept", "text/plain")]);
741        let result = parse_data_request(&headers, None, false);
742
743        match result {
744            Err(ApiError::BadRequest(msg)) => {
745                assert!(msg.contains("Unsupported Accept"), "unexpected msg: {msg}");
746            }
747            other => panic!("expected BadRequest, got {other:?}"),
748        }
749    }
750
751    #[test]
752    fn malformed_accept_header_returns_bad_request() {
753        let mut headers = HeaderMap::new();
754        // Insert raw bytes that are not valid UTF-8
755        headers.insert(
756            header::ACCEPT,
757            HeaderValue::from_bytes(b"\xFF\xFE").unwrap(),
758        );
759        let result = parse_data_request(&headers, None, false);
760
761        assert!(
762            matches!(result, Err(ApiError::BadRequest(_))),
763            "expected BadRequest for malformed Accept"
764        );
765    }
766
767    #[test]
768    fn octet_stream_with_include_schema_returns_bad_request() {
769        let headers = headers_with(&[("accept", "application/octet-stream")]);
770        let result = parse_data_request(&headers, None, true);
771
772        match result {
773            Err(ApiError::BadRequest(msg)) => {
774                assert!(msg.contains("include-schema"), "unexpected msg: {msg}");
775            }
776            other => panic!("expected BadRequest, got {other:?}"),
777        }
778    }
779
780    #[test]
781    fn json_body_is_parsed_into_parameter_map() {
782        let headers = headers_with(&[
783            ("content-type", "application/json"),
784            ("accept", "application/json"),
785        ]);
786        let body = Bytes::from_static(br#"{"data":{"rpm":1200}}"#);
787        let result = parse_data_request(&headers, Some(body), false);
788
789        let parsed = result.expect("should succeed");
790        assert!(parsed.map_to_json);
791
792        match parsed.data {
793            Some(cda_interfaces::diagservices::UdsPayloadData::ParameterMap(map)) => {
794                assert_eq!(
795                    map.get("rpm"),
796                    Some(&serde_json::json!(1200)),
797                    "expected 'rpm' key in parameter map"
798                );
799            }
800            other => panic!("expected ParameterMap, got {other:?}"),
801        }
802    }
803
804    #[test]
805    fn invalid_json_body_returns_bad_request() {
806        let headers = headers_with(&[
807            ("content-type", "application/json"),
808            ("accept", "application/json"),
809        ]);
810        let body = Bytes::from_static(b"not-json");
811        let result = parse_data_request(&headers, Some(body), false);
812
813        match result {
814            Err(ApiError::BadRequest(msg)) => {
815                assert!(msg.contains("Invalid JSON"), "unexpected msg: {msg}");
816            }
817            other => panic!("expected BadRequest, got {other:?}"),
818        }
819    }
820
821    #[test]
822    fn octet_stream_body_is_parsed_into_raw() {
823        let payload: &[u8] = &[0x22, 0x01, 0xFF];
824        let headers = headers_with(&[
825            ("content-type", "application/octet-stream"),
826            ("accept", "application/octet-stream"),
827            ("content-length", &payload.len().to_string()),
828        ]);
829        let body = Bytes::copy_from_slice(payload);
830        let result = parse_data_request(&headers, Some(body), false);
831
832        let parsed = result.expect("should succeed");
833        assert!(!parsed.map_to_json);
834
835        match parsed.data {
836            Some(cda_interfaces::diagservices::UdsPayloadData::Raw(bytes)) => {
837                assert_eq!(bytes, payload);
838            }
839            other => panic!("expected Raw, got {other:?}"),
840        }
841    }
842}