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}