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