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}