1/*
2 * SPDX-FileCopyrightText: 2025 Copyright (c) Contributors to the Eclipse Foundation
3 *
4 * See the NOTICE file(s) distributed with this work for additional
5 * information regarding copyright ownership.
6 *
7 * This program and the accompanying materials are made available under the
8 * terms of the Apache License Version 2.0 which is available at
9 * https://www.apache.org/licenses/LICENSE-2.0
10 *
11 * SPDX-License-Identifier: Apache-2.0
12 */
13
14use aide::{UseApi, transform::TransformOperation};
15use axum::{
16 Json,
17 extract::{Query, State},
18 http::StatusCode,
19 response::{IntoResponse as _, Response},
20};
21use axum_extra::extract::WithRejection;
22use cda_interfaces::{SchemaProvider, UdsEcu, file_manager::FileManager};
23use cda_plugin_security::Secured;
24use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
25
26use crate::sovd::{
27 WebserverEcuState, create_schema,
28 error::{ApiError, ErrorWrapper},
29};
30
31pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
32 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
33 WithRejection(Query(query), _): WithRejection<
34 Query<sovd_interfaces::IncludeSchemaQuery>,
35 ApiError,
36 >,
37 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
38) -> Response {
39 use cda_interfaces::DynamicPlugin;
40 let security_plugin: DynamicPlugin = security_plugin;
41 match uds
42 .get_components_operations_info(&ecu_name, &security_plugin)
43 .await
44 {
45 Ok(items) => {
46 let schema = if query.include_schema {
47 Some(create_schema!(
48 sovd_interfaces::Items<OperationCollectionItem>
49 ))
50 } else {
51 None
52 };
53 (
54 StatusCode::OK,
55 Json(sovd_interfaces::Items {
56 items: items
57 .into_iter()
58 .map(|info| OperationCollectionItem {
59 id: info.id,
60 name: info.name,
61 proximity_proof_required: false,
62 asynchronous_execution: info.has_stop || info.has_request_results,
63 })
64 .collect(),
65 schema,
66 }),
67 )
68 .into_response()
69 }
70 Err(e) => ErrorWrapper {
71 error: e.into(),
72 include_schema: query.include_schema,
73 }
74 .into_response(),
75 }
76}
77
78pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
79 op.description("Get all available operations for this ECU component")
80 .response_with::<200, Json<sovd_interfaces::Items<OperationCollectionItem>>, _>(|res| {
81 res.description("List of operations available on this ECU.")
82 })
83}
84
85pub(crate) mod comparams {
86
87 pub(crate) mod executions {
88 use std::sync::Arc;
89
90 use aide::{UseApi, transform::TransformOperation};
91 use axum::{
92 Json,
93 extract::{OriginalUri, Path, Query, State},
94 http::{StatusCode, header},
95 response::{IntoResponse as _, Response},
96 };
97 use axum_extra::extract::WithRejection;
98 use cda_interfaces::{HashMap, HashMapExtensions, UdsEcu, file_manager::FileManager};
99 use indexmap::IndexMap;
100 use opensovd_axum_extra::ExtractHost;
101 use sovd_interfaces::components::ecu::operations::comparams as sovd_comparams;
102 use tokio::sync::RwLock;
103 use uuid::Uuid;
104
105 use crate::sovd::{
106 IntoSovd, WebserverEcuState, create_schema,
107 error::{ApiError, ErrorWrapper},
108 };
109
110 fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
111 Uuid::parse_str(id).map_err(|e| ErrorWrapper {
112 error: ApiError::BadRequest(format!("{e:?}")),
113 include_schema,
114 })
115 }
116
117 pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
118 WithRejection(Query(query), _): WithRejection<
119 Query<sovd_comparams::executions::get::Query>,
120 ApiError,
121 >,
122 State(WebserverEcuState {
123 comparam_executions,
124 ..
125 }): State<WebserverEcuState<T, U>>,
126 ) -> Response {
127 handler_read(comparam_executions, query.include_schema).await
128 }
129
130 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
131 op.description("Get all comparam executions")
132 .response_with::<200, Json<sovd_comparams::executions::get::Response>, _>(|res| {
133 res.description("Response with all comparam executions.")
134 .example(sovd_comparams::executions::get::Response {
135 items: vec![sovd_comparams::executions::Item {
136 id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
137 }],
138 schema: None,
139 })
140 })
141 }
142
143 pub(crate) async fn post<T: UdsEcu + Clone, U: FileManager>(
144 WithRejection(Query(query), _): WithRejection<
145 Query<sovd_comparams::executions::get::Query>,
146 ApiError,
147 >,
148 State(WebserverEcuState {
149 comparam_executions,
150 update_in_progress,
151 ..
152 }): State<WebserverEcuState<T, U>>,
153 UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
154 OriginalUri(uri): OriginalUri,
155 request_body: Option<Json<sovd_comparams::executions::update::Request>>,
156 ) -> Response {
157 let path = format!("http://{host}{uri}");
158 let body = if let Some(Json(body)) = request_body {
159 Some(body)
160 } else {
161 None
162 };
163 handler_write(
164 comparam_executions,
165 path,
166 body,
167 query.include_schema,
168 &update_in_progress,
169 )
170 .await
171 }
172
173 pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
174 op.description("Create a new comparam execution")
175 .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
176 |res| {
177 res.description("Comparam execution created successfully.")
178 .example(sovd_comparams::executions::update::Response {
179 id: "b7e2c1a2-3f4d-4e6a-9c8b-2a1d5e7f8c9b".to_string(),
180 status: sovd_comparams::executions::Status::Running,
181 schema: None,
182 })
183 },
184 )
185 }
186
187 pub(crate) async fn handler_read(
188 executions: Arc<RwLock<IndexMap<Uuid, sovd_comparams::Execution>>>,
189 include_schema: bool,
190 ) -> Response {
191 let schema = if include_schema {
192 Some(create_schema!(sovd_comparams::executions::get::Response))
193 } else {
194 None
195 };
196 (
197 StatusCode::OK,
198 Json(sovd_comparams::executions::get::Response {
199 items: executions
200 .read()
201 .await
202 .keys()
203 .map(|id| sovd_comparams::executions::Item { id: id.to_string() })
204 .collect::<Vec<_>>(),
205 schema,
206 }),
207 )
208 .into_response()
209 }
210 async fn handler_write(
211 executions: Arc<RwLock<IndexMap<Uuid, sovd_comparams::Execution>>>,
212 base_path: String,
213 request: Option<sovd_comparams::executions::update::Request>,
214 include_schema: bool,
215 update_in_progress: &std::sync::atomic::AtomicBool,
216 ) -> Response {
217 // todo: not in scope for now: request can take body with
218 // { timeout: INT, parameters: { ... }, proximity_response: STRING }
219 let mut executions = executions.write().await;
220 if update_in_progress.load(std::sync::atomic::Ordering::Acquire) {
221 return ErrorWrapper {
222 error: ApiError::Conflict(
223 "Runtime update in progress, operation blocked".to_owned(),
224 ),
225 include_schema,
226 }
227 .into_response();
228 }
229 let id = Uuid::new_v4();
230 let mut comparam_override: HashMap<String, sovd_comparams::ComParamValue> =
231 HashMap::new();
232
233 if let Some(sovd_comparams::executions::update::Request {
234 parameters: Some(parameters),
235 ..
236 }) = request
237 {
238 for (k, v) in parameters {
239 comparam_override.insert(k, v);
240 }
241 }
242
243 let schema = if include_schema {
244 Some(create_schema!(sovd_comparams::executions::update::Response))
245 } else {
246 None
247 };
248
249 let create_execution_response = sovd_comparams::executions::update::Response {
250 id: id.to_string(),
251 status: sovd_comparams::executions::Status::Running,
252 schema,
253 };
254 executions.insert(
255 id,
256 sovd_comparams::Execution {
257 capability: sovd_comparams::executions::Capability::Execute,
258 status: create_execution_response.status.clone(),
259 comparam_override,
260 },
261 );
262 (
263 StatusCode::ACCEPTED,
264 [(header::LOCATION, format!("{base_path}/{id}"))],
265 Json(create_execution_response),
266 )
267 .into_response()
268 }
269
270 pub(crate) mod id {
271 use super::*;
272 use crate::{openapi, sovd::components::IdPathParam};
273 pub(crate) async fn get<T: UdsEcu + Clone, U: FileManager>(
274 Path(id): Path<IdPathParam>,
275 WithRejection(Query(query), _): WithRejection<
276 Query<sovd_comparams::executions::get::Query>,
277 ApiError,
278 >,
279 State(WebserverEcuState {
280 ecu_name,
281 uds,
282 comparam_executions,
283 ..
284 }): State<WebserverEcuState<T, U>>,
285 ) -> Response {
286 let include_schema = query.include_schema;
287 let id = match parse_exec_uuid(&id, include_schema) {
288 Ok(v) => v,
289 Err(e) => return e.into_response(),
290 };
291 let mut executions: Vec<sovd_comparams::Execution> = Vec::new();
292
293 let (idx, execution) = match comparam_executions
294 .read()
295 .await
296 .get_full(&id)
297 .ok_or_else(|| {
298 ApiError::NotFound(Some(format!("Execution with id {id} not found")))
299 }) {
300 Ok((idx, _, v)) => (idx, v.clone()),
301 Err(e) => {
302 return ErrorWrapper {
303 error: e,
304 include_schema,
305 }
306 .into_response();
307 }
308 };
309 let capability = execution.capability.clone();
310 let status = execution.status.clone();
311
312 // put in all executions with lower index than this one
313 for (_, v) in &comparam_executions.read().await.as_slice()[..idx] {
314 executions.push(v.clone());
315 }
316 executions.push(execution);
317
318 let mut parameters = match uds.get_comparams(&ecu_name).await {
319 Ok(v) => v.into_sovd(),
320 Err(e) => {
321 return ErrorWrapper {
322 error: e.into(),
323 include_schema,
324 }
325 .into_response();
326 }
327 };
328
329 for (k, v) in executions.into_iter().flat_map(|e| e.comparam_override) {
330 parameters.insert(k, v);
331 }
332
333 let schema = if include_schema {
334 Some(create_schema!(
335 sovd_comparams::executions::id::get::Response
336 ))
337 } else {
338 None
339 };
340
341 (
342 StatusCode::OK,
343 Json(sovd_comparams::executions::id::get::Response {
344 capability,
345 parameters,
346 status,
347 schema,
348 }),
349 )
350 .into_response()
351 }
352
353 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
354 op.description("Get a specific comparam execution")
355 .response_with::<200, Json<sovd_comparams::executions::id::get::Response>, _>(
356 |res| {
357 res.description("Response with comparam execution details.")
358 .example(sovd_comparams::executions::id::get::Response {
359 capability: sovd_comparams::executions::Capability::Execute,
360 parameters: HashMap::new(),
361 status: sovd_comparams::executions::Status::Running,
362 schema: None,
363 })
364 },
365 )
366 .with(openapi::comparam_execution_errors)
367 }
368
369 pub(crate) async fn delete<T: UdsEcu + Clone, U: FileManager>(
370 Path(id): Path<IdPathParam>,
371 State(WebserverEcuState {
372 comparam_executions,
373 ..
374 }): State<WebserverEcuState<T, U>>,
375 ) -> Response {
376 let id = match parse_exec_uuid(&id, false) {
377 Ok(v) => v,
378 Err(e) => return e.into_response(),
379 };
380 let mut executions = comparam_executions.write().await;
381 if executions.shift_remove(&id).is_none() {
382 return ErrorWrapper {
383 error: ApiError::NotFound(Some(format!(
384 "Execution with id {id} not found"
385 ))),
386 include_schema: false,
387 }
388 .into_response();
389 }
390 StatusCode::NO_CONTENT.into_response()
391 }
392
393 pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
394 op.description("Delete a specific comparam execution")
395 .response_with::<204, (), _>(|res| {
396 res.description("Comparam execution deleted successfully.")
397 })
398 .with(openapi::comparam_execution_errors)
399 }
400
401 pub(crate) async fn put<T: UdsEcu + Clone, U: FileManager>(
402 Path(id): Path<IdPathParam>,
403 WithRejection(Query(query), _): WithRejection<
404 Query<sovd_comparams::executions::update::Query>,
405 ApiError,
406 >,
407 State(WebserverEcuState {
408 comparam_executions,
409 ..
410 }): State<WebserverEcuState<T, U>>,
411 UseApi(ExtractHost(host), _): UseApi<ExtractHost, String>,
412 OriginalUri(uri): OriginalUri,
413 WithRejection(Json(request), _): WithRejection<
414 Json<sovd_comparams::executions::update::Request>,
415 ApiError,
416 >,
417 ) -> Response {
418 let include_schema = query.include_schema;
419 let id = match parse_exec_uuid(&id, include_schema) {
420 Ok(v) => v,
421 Err(e) => return e.into_response(),
422 };
423 let path = format!("http://{host}{uri}");
424 // todo: (out of scope for now) handle timout and capability
425
426 // todo: validate that the passed in CP is actually a valid CP for the ECU
427 // let mut comparams = match uds.get_comparams(&ecu_name).await {
428 // Ok(v) => v,
429 // Err(e) => return ErrorWrapper(ApiError::BadRequest(e)).into_response(),
430 // };
431
432 let mut executions_lock = comparam_executions.write().await;
433 let execution: &mut sovd_comparams::Execution =
434 match executions_lock.get_mut(&id).ok_or_else(|| {
435 ApiError::NotFound(Some(format!("Execution with id {id} not found")))
436 }) {
437 Ok(v) => v,
438 Err(e) => {
439 return ErrorWrapper {
440 error: e,
441 include_schema,
442 }
443 .into_response();
444 }
445 };
446
447 if let Some(comparam_values) = request.parameters {
448 for (k, v) in comparam_values {
449 execution.comparam_override.insert(k, v);
450 }
451 }
452
453 let schema = if include_schema {
454 Some(create_schema!(sovd_comparams::executions::update::Response))
455 } else {
456 None
457 };
458
459 (
460 StatusCode::ACCEPTED,
461 [(header::LOCATION, path)],
462 Json(sovd_comparams::executions::update::Response {
463 id: id.to_string(),
464 status: execution.status.clone(),
465 schema,
466 }),
467 )
468 .into_response()
469 }
470
471 pub(crate) fn docs_put(op: TransformOperation) -> TransformOperation {
472 op.description("Update a specific comparam execution")
473 .response_with::<202, Json<sovd_comparams::executions::update::Response>, _>(
474 |res| {
475 res.description("Comparam execution updated successfully.")
476 .example(sovd_comparams::executions::update::Response {
477 id: "example_id".to_string(),
478 status: sovd_comparams::executions::Status::Running,
479 schema: None,
480 })
481 },
482 )
483 .with(openapi::comparam_execution_errors)
484 }
485 }
486 }
487}
488
489pub(crate) mod service {
490 /// `GET /operations/{service}` - get operation details or SDGs
[docs] 491 // [[ dimpl~sovd-api-component-operations-sdgsd, GET /operations/{service} SDG handler ]]
492 pub(crate) async fn get<
493 T: cda_interfaces::UdsEcu + cda_interfaces::SchemaProvider + Clone,
494 U: cda_interfaces::file_manager::FileManager,
495 >(
496 aide::UseApi(cda_plugin_security::Secured(security_plugin), _): aide::UseApi<
497 cda_plugin_security::Secured,
498 (),
499 >,
500 axum::extract::Path(docs_endpoint::OperationNamePathParam { service }): axum::extract::Path<
501 docs_endpoint::OperationNamePathParam,
502 >,
503 axum_extra::extract::WithRejection(axum::extract::Query(query), _): axum_extra::extract::WithRejection<
504 axum::extract::Query<sovd_interfaces::components::ComponentQuery>,
505 crate::sovd::error::ApiError,
506 >,
507 axum::extract::State(crate::sovd::WebserverEcuState { ecu_name, uds, .. }): axum::extract::State<
508 crate::sovd::WebserverEcuState<T, U>,
509 >,
510 ) -> axum::response::Response {
511 use axum::response::IntoResponse as _;
512
513 let include_schema = query.include_schema;
514 if query.include_sdgs {
515 return get_sdgs_handler::<T>(service, &ecu_name, &uds, include_schema).await;
516 }
517
518 // Normal response: return operation info for the named service
519 let security_plugin: cda_interfaces::DynamicPlugin = security_plugin;
520 let ops_info = match uds
521 .get_components_operations_info(&ecu_name, &security_plugin)
522 .await
523 {
524 Ok(info) => info,
525 Err(e) => {
526 return crate::sovd::error::ErrorWrapper {
527 error: e.into(),
528 include_schema,
529 }
530 .into_response();
531 }
532 };
533
534 let Some(op_info) = ops_info
535 .iter()
536 .find(|o| o.id.eq_ignore_ascii_case(&service))
537 else {
538 return crate::sovd::error::ApiError::NotFound(Some(format!(
539 "Operation '{service}' not found"
540 )))
541 .into_response();
542 };
543
544 let schema = if include_schema {
545 Some(crate::sovd::create_schema!(
546 sovd_interfaces::components::ecu::operations::OperationCollectionItem
547 ))
548 } else {
549 None
550 };
551
552 (
553 http::StatusCode::OK,
554 axum::Json(sovd_interfaces::Items {
555 items: vec![
556 sovd_interfaces::components::ecu::operations::OperationCollectionItem {
557 id: op_info.id.clone(),
558 name: op_info.name.clone(),
559 proximity_proof_required: false,
560 asynchronous_execution: op_info.has_stop || op_info.has_request_results,
561 },
562 ],
563 schema,
564 }),
565 )
566 .into_response()
567 }
568
569 async fn get_sdgs_handler<T: cda_interfaces::UdsEcu + Clone>(
570 service: String,
571 ecu_name: &str,
572 gateway: &T,
573 include_schema: bool,
574 ) -> axum::response::Response {
575 use axum::response::IntoResponse as _;
576 use cda_interfaces::{DiagComm, DiagCommType, HashMap, HashMapExtensions};
577
578 use crate::sovd::IntoSovd;
579
580 let service_ops = vec![
581 DiagComm {
582 name: service.clone(),
583 type_: DiagCommType::Operations,
584 lookup_name: None,
585 subfunction_id: None,
586 },
587 DiagComm {
588 name: service.clone(),
589 type_: DiagCommType::Operations,
590 lookup_name: None,
591 subfunction_id: None,
592 },
593 DiagComm {
594 name: service,
595 type_: DiagCommType::Operations,
596 lookup_name: None,
597 subfunction_id: None,
598 },
599 ];
600 let schema = if include_schema {
601 Some(crate::sovd::create_schema!(
602 sovd_interfaces::components::ecu::ServicesSdgs
603 ))
604 } else {
605 None
606 };
607 let mut resp = sovd_interfaces::components::ecu::ServicesSdgs {
608 items: HashMap::new(),
609 schema,
610 };
611 for service in service_ops {
612 match gateway.get_sdgs(ecu_name, Some(&service)).await {
613 Ok(sdgs) => {
614 if sdgs.is_empty() {
615 continue;
616 }
617 resp.items.insert(
618 format!("{}_{:?}", service.name, service.action()).to_lowercase(),
619 sovd_interfaces::components::ecu::ServiceSdgs {
620 sdgs: sdgs.into_sovd(),
621 },
622 );
623 }
624 Err(e) => {
625 return crate::sovd::error::ErrorWrapper {
626 error: e.into(),
627 include_schema,
628 }
629 .into_response();
630 }
631 }
632 }
633 (http::StatusCode::OK, axum::Json(resp)).into_response()
634 }
635
636 pub(crate) fn docs_get(
637 op: aide::transform::TransformOperation,
638 ) -> aide::transform::TransformOperation {
639 use aide::transform::TransformParameter;
640 op.description("Get a specific operation or its SDG metadata.")
641 .parameter("x-sovd2uds-includesdgs", |op: TransformParameter<bool>| {
642 op.description("Set to true to include sdgs.")
643 })
644 .response_with::<200, axum::Json<
645 sovd_interfaces::Items<
646 sovd_interfaces::components::ecu::operations::OperationCollectionItem,
647 >,
648 >, _>(|res| res.description("Operation details or SDG metadata."))
649 .with(crate::openapi::error_not_found)
650 .with(crate::openapi::error_internal_server)
651 }
652
653 /// `GET /operations/{service}/docs` - online capability description
654 pub(crate) mod docs_endpoint {
655 use aide::{UseApi, openapi::OpenApi, transform::TransformOperation};
656 use axum::{
657 Json,
658 extract::{Path, State},
659 http::StatusCode,
660 response::{IntoResponse as _, Response},
661 };
662 use cda_interfaces::{
663 DiagComm, DiagCommType, DynamicPlugin, SchemaProvider, UdsEcu,
664 file_manager::FileManager, subfunction_ids,
665 };
666 use cda_plugin_security::Secured;
667
668 use crate::{
669 openapi,
670 sovd::{
671 WebserverEcuState,
672 docs::{self, operations::OperationDocsMeta},
673 error::ApiError,
674 },
675 };
676
677 openapi::aide_helper::gen_path_param!(OperationNamePathParam service String);
678
679 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
680 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
681 Path(OperationNamePathParam { service }): Path<OperationNamePathParam>,
682 State(WebserverEcuState { ecu_name, uds, .. }): State<WebserverEcuState<T, U>>,
683 ) -> Response {
684 let security_plugin: DynamicPlugin = security_plugin;
685
686 let ops_info = match uds
687 .get_components_operations_info(&ecu_name, &security_plugin)
688 .await
689 {
690 Ok(info) => info,
691 Err(e) => return ApiError::from(e).into_response(),
692 };
693
694 let Some(op_info) = ops_info
695 .iter()
696 .find(|o| o.id.eq_ignore_ascii_case(&service))
697 else {
698 return ApiError::NotFound(Some(format!("Operation '{service}' not found")))
699 .into_response();
700 };
701
702 let is_async = op_info.has_stop || op_info.has_request_results;
703
704 let diag_service = DiagComm {
705 name: service.clone(),
706 type_: DiagCommType::Operations,
707 lookup_name: None,
708 subfunction_id: Some(subfunction_ids::routine::START),
709 };
710
711 let request_params_schema = uds
712 .schema_for_request(&ecu_name, &diag_service)
713 .await
714 .ok()
715 .and_then(cda_interfaces::SchemaDescription::into_schema);
716
717 let response_params_schema = uds
718 .schema_for_responses(&ecu_name, &diag_service)
719 .await
720 .ok()
721 .and_then(cda_interfaces::SchemaDescription::into_schema);
722
723 let meta = OperationDocsMeta {
724 name: service.clone(),
725 is_async,
726 request_params_schema,
727 response_params_schema,
728 };
729
730 let base_path = format!("/components/{ecu_name}/operations/{service}");
731 let path_items = docs::operations::build_path_items(&base_path, &meta);
732 let doc = docs::build_openapi_doc(&format!("Operation: {service}"), path_items);
733
734 (StatusCode::OK, Json(doc)).into_response()
735 }
736
737 pub(crate) fn docs_transform(op: TransformOperation) -> TransformOperation {
738 op.description(
739 "Online capability description for a specific operation on this ECU component \
740 (ISO 17978-3 Section 7.5). Returns a self-contained OpenAPI specification.",
741 )
742 .response_with::<200, Json<OpenApi>, _>(|res| {
743 res.description("Self-contained OpenAPI 3.1 specification for this operation.")
744 })
745 .with(openapi::error_not_found)
746 }
747 }
748
749 pub(crate) mod executions {
750 use aide::{UseApi, transform::TransformOperation};
751 use axum::{
752 Json,
753 body::Bytes,
754 extract::{OriginalUri, Path, Query, State},
755 http::{HeaderMap, StatusCode, header},
756 response::{IntoResponse as _, Response},
757 };
758 use axum_extra::extract::{Host, WithRejection};
759 use cda_interfaces::{
760 DiagComm, DiagCommType, DynamicPlugin, SchemaProvider, UdsEcu,
761 diagservices::{DiagServiceJsonResponse, DiagServiceResponse, DiagServiceResponseType},
762 file_manager::FileManager,
763 subfunction_ids,
764 };
765 use cda_plugin_security::{Secured, SecurityPlugin};
766 use sovd_interfaces::{
767 common::operations::OperationIdItem,
768 components::ecu::operations::{
769 AsyncGetByIdResponse, AsyncPostResponse, ExecutionStatus, OperationDeleteQuery,
770 OperationQuery, service::executions as sovd_executions,
771 },
772 };
773 use uuid::Uuid;
774
775 use crate::{
776 openapi,
777 sovd::{
778 self, ServiceExecution, WebserverEcuState, api_error_from_diag_response,
779 components::get_content_type_and_accept,
780 create_response_schema, create_schema,
781 error::{ApiError, ErrorWrapper, VendorErrorCode},
782 field_parse_errors_to_json, finalize_execution, guard_execution,
783 locks::validate_lock,
784 remove_reserved_execution, reserve_execution,
785 },
786 };
787
788 openapi::aide_helper::gen_path_param!(OperationServicePathParam service String);
789
790 /// Options forwarded from the HTTP layer into `ecu_operation_write_handler`.
791 pub(crate) struct WriteHandlerOptions {
792 pub include_schema: bool,
793 pub suppress_service: bool,
794 pub base_path: String,
795 }
796
797 /// Request data forwarded from the HTTP layer into `ecu_operation_write_handler`.
798 pub(crate) struct WriteHandlerRequest {
799 pub service: String,
800 pub headers: HeaderMap,
801 pub body: Bytes,
802 }
803
804 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
805 UseApi(Secured(_security_plugin), _): UseApi<Secured, ()>,
806 Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
807 WithRejection(Query(query), _): WithRejection<Query<sovd_executions::Query>, ApiError>,
808 State(WebserverEcuState {
809 service_executions, ..
810 }): State<WebserverEcuState<T, U>>,
811 ) -> Response {
812 let schema = if query.include_schema {
813 Some(create_schema!(sovd_interfaces::Items<OperationIdItem>))
814 } else {
815 None
816 };
817 let ids: Vec<OperationIdItem> = service_executions
818 .read()
819 .await
820 .get(&service)
821 .map(|op_map| {
822 op_map
823 .iter()
824 .filter(|(_, v)| v.is_created)
825 .map(|(k, _)| OperationIdItem { id: k.to_string() })
826 .collect()
827 })
828 .unwrap_or_default();
829 (
830 StatusCode::OK,
831 Json(sovd_interfaces::Items { items: ids, schema }),
832 )
833 .into_response()
834 }
835
836 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
837 op.description("List all active service operation executions")
838 .response_with::<200, Json<sovd_interfaces::Items<String>>, _>(|res| {
839 res.description("List of active execution ids.")
840 })
841 }
842
843 #[allow(
844 clippy::too_many_arguments,
845 reason = "Axum extractors cannot be combined without a new custom extractor"
846 )]
847 pub(crate) async fn post<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
848 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
849 Path(OperationServicePathParam { service }): Path<OperationServicePathParam>,
850 WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
851 State(WebserverEcuState {
852 ecu_name,
853 uds,
854 locks,
855 service_executions,
856 update_in_progress,
857 ..
858 }): State<WebserverEcuState<T, U>>,
859 UseApi(Host(host), _): UseApi<Host, String>,
860 OriginalUri(uri): OriginalUri,
861 headers: HeaderMap,
862 body: Bytes,
863 ) -> Response {
864 let claims = security_plugin.as_auth_plugin().claims();
865 if let Some(response) =
866 validate_lock(&claims, &ecu_name, &locks, query.include_schema).await
867 {
868 return response;
869 }
870 ecu_operation_write_handler::<T>(
871 WriteHandlerRequest {
872 service,
873 headers,
874 body,
875 },
876 &ecu_name,
877 &uds,
878 service_executions,
879 security_plugin,
880 WriteHandlerOptions {
881 include_schema: query.include_schema,
882 suppress_service: query.suppress_service,
883 base_path: format!("http://{host}{uri}"),
884 },
885 &update_in_progress,
886 )
887 .await
888 }
889
890 pub(crate) fn docs_post(op: TransformOperation) -> TransformOperation {
891 openapi::request_json_and_octet::<sovd_executions::Request>(op)
892 .description("Start a new operation execution (Start subfunction)")
893 .response_with::<200, Json<sovd_executions::Response<VendorErrorCode>>, _>(|res| {
894 let mut res = res
895 .description("Execution started, synchronous result.")
896 .example(sovd_executions::Response {
897 parameters: Some(serde_json::Map::from_iter([(
898 "example_param".to_string(),
899 serde_json::Value::String("example_value".to_string()),
900 )])),
901 error: None,
902 schema: None,
903 });
904 res.inner().content.insert(
905 "application/octet-stream".to_owned(),
906 aide::openapi::MediaType {
907 example: Some(serde_json::json!([0xABu8, 0xCD, 0xEF, 0x00])),
908 ..Default::default()
909 },
910 );
911 res
912 })
913 .response_with::<202, Json<AsyncPostResponse>, _>(|res| {
914 res.description(
915 "Execution started asynchronously. Use the returned id for GET/DELETE on \
916 /executions/{id}.",
917 )
918 })
919 .with(openapi::error_bad_request)
920 .with(openapi::error_not_found)
921 .with(openapi::error_forbidden)
922 .with(openapi::error_conflict)
923 .with(openapi::error_internal_server)
924 .with(openapi::error_bad_gateway)
925 }
926
927 fn validate_and_parse_write_request(
928 headers: &HeaderMap,
929 body: &Bytes,
930 ) -> Result<(Option<cda_interfaces::diagservices::UdsPayloadData>, bool), ApiError>
931 {
932 let (Some(content_type), accept) = get_content_type_and_accept(headers)? else {
933 return Err(ApiError::BadRequest("Missing Content-Type".to_owned()));
934 };
935 let data = sovd::get_payload_data::<sovd_executions::Request>(
936 Some(&content_type),
937 headers,
938 body,
939 )?;
940 if accept != mime::APPLICATION_OCTET_STREAM && accept != mime::APPLICATION_JSON {
941 return Err(ApiError::BadRequest(format!(
942 "Unsupported Accept header: {accept:?}"
943 )));
944 }
945 Ok((data, accept == mime::APPLICATION_JSON))
946 }
947
948 pub(crate) async fn ecu_operation_write_handler<T: UdsEcu + SchemaProvider + Clone>(
949 req: WriteHandlerRequest,
950 ecu_name: &str,
951 uds: &T,
952 service_executions: std::sync::Arc<
953 tokio::sync::RwLock<
954 cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
955 >,
956 >,
957 security_plugin: Box<dyn SecurityPlugin>,
958 opts: WriteHandlerOptions,
959 update_in_progress: &std::sync::atomic::AtomicBool,
960 ) -> Response {
961 let WriteHandlerRequest {
962 service,
963 headers,
964 body,
965 } = req;
966 let WriteHandlerOptions {
967 include_schema,
968 suppress_service,
969 base_path,
970 } = opts;
971 let err_response = |error: ApiError| -> Response {
972 ErrorWrapper {
973 error,
974 include_schema,
975 }
976 .into_response()
977 };
978 if service == "reset" {
979 return ecu_reset_handler::<T>(
980 service,
981 ecu_name,
982 uds,
983 body,
984 security_plugin,
985 include_schema,
986 )
987 .await;
988 }
989
990 // Reserve an execution slot atomically: checks for a running
991 // conflict and, if none, inserts a placeholder so that a second
992 // concurrent POST for the same operation sees 409 Conflict.
993 let exec_id = match reserve_execution(
994 &service_executions,
995 &service,
996 &service,
997 include_schema,
998 update_in_progress,
999 )
1000 .await
1001 {
1002 Ok(id) => id,
1003 Err(err) => return err.into_response(),
1004 };
1005
1006 let security_plugin: DynamicPlugin = security_plugin;
1007 let is_async = if suppress_service {
1008 true
1009 } else {
1010 match check_if_async(uds, ecu_name, &service, &security_plugin).await {
1011 Ok(v) => v,
1012 Err(e) => {
1013 remove_reserved_execution(&service_executions, &service, &exec_id).await;
1014 return err_response(e);
1015 }
1016 }
1017 };
1018
1019 let (data, map_to_json) = match validate_and_parse_write_request(&headers, &body) {
1020 Ok(v) => v,
1021 Err(e) => {
1022 remove_reserved_execution(&service_executions, &service, &exec_id).await;
1023 return err_response(e);
1024 }
1025 };
1026
1027 let diag_service = DiagComm {
1028 name: service.clone(),
1029 type_: DiagCommType::Operations,
1030 lookup_name: None,
1031 subfunction_id: Some(subfunction_ids::routine::START),
1032 };
1033 let response = if suppress_service {
1034 None
1035 } else {
1036 match send_start_request(
1037 uds,
1038 ecu_name,
1039 diag_service.clone(),
1040 &security_plugin,
1041 data,
1042 map_to_json,
1043 include_schema,
1044 )
1045 .await
1046 {
1047 Ok(r) => r,
1048 Err(e) => {
1049 remove_reserved_execution(&service_executions, &service, &exec_id).await;
1050 return e;
1051 }
1052 }
1053 };
1054
1055 if is_async {
1056 handle_async_post::<T>(
1057 response,
1058 map_to_json,
1059 include_schema,
1060 base_path,
1061 service,
1062 service_executions,
1063 exec_id,
1064 )
1065 .await
1066 } else {
1067 remove_reserved_execution(&service_executions, &service, &exec_id).await;
1068 handle_sync_post::<T>(
1069 response,
1070 map_to_json,
1071 include_schema,
1072 ecu_name,
1073 uds,
1074 &diag_service,
1075 )
1076 .await
1077 }
1078 }
1079
1080 /// Returns whether the operation is async (has Stop or `RequestResults`
1081 /// subfunctions).
1082 async fn check_if_async<T: UdsEcu>(
1083 uds: &T,
1084 ecu_name: &str,
1085 service: &str,
1086 security_plugin: &DynamicPlugin,
1087 ) -> Result<bool, ApiError> {
1088 let sf = uds
1089 .get_routine_subfunctions(ecu_name, service, security_plugin)
1090 .await
1091 .map_err(ApiError::from)?;
1092 Ok(sf.has_stop || sf.has_request_results)
1093 }
1094
1095 /// Sends the Start subfunction request and returns the positive response, or
1096 /// `Err(Response)` if the UDS call failed or returned a negative response.
1097 async fn send_start_request<T: UdsEcu>(
1098 uds: &T,
1099 ecu_name: &str,
1100 diag_service: DiagComm,
1101 security_plugin: &DynamicPlugin,
1102 data: Option<cda_interfaces::diagservices::UdsPayloadData>,
1103 map_to_json: bool,
1104 include_schema: bool,
1105 ) -> Result<Option<T::Response>, Response> {
1106 let response = match uds
1107 .send(ecu_name, diag_service, security_plugin, data, map_to_json)
1108 .await
1109 {
1110 Ok(v) => v,
1111 Err(e) => {
1112 return Err(ErrorWrapper {
1113 error: e.into(),
1114 include_schema,
1115 }
1116 .into_response());
1117 }
1118 };
1119 if let DiagServiceResponseType::Negative = response.response_type() {
1120 return Err(api_error_from_diag_response(&response, include_schema).into_response());
1121 }
1122 Ok(Some(response))
1123 }
1124
1125 fn err_invalid_content(
1126 detail: String,
1127 ) -> sovd_interfaces::error::DataError<VendorErrorCode> {
1128 sovd_interfaces::error::DataError {
1129 path: String::new(),
1130 error: sovd_interfaces::error::ApiErrorResponse {
1131 message: detail,
1132 error_code: sovd_interfaces::error::ErrorCode::InvalidResponseContent,
1133 vendor_code: Some(VendorErrorCode::ErrorInterpretingMessage),
1134 parameters: None,
1135 error_source: None,
1136 schema: None,
1137 },
1138 }
1139 }
1140
1141 /// Parses a positive, non-empty UDS response into `(parameters, errors)`.
1142 ///
1143 /// - `Object` -> parameters map + any field-parse errors
1144 /// - `Null` -> empty parameters, no errors (ECU signalled no output)
1145 /// - anything else, or a parse failure -> empty parameters, one soft `DataError`
1146 fn parse_json_response_params<R: DiagServiceResponse>(
1147 response: R,
1148 context: &str,
1149 ) -> (
1150 serde_json::Map<String, serde_json::Value>,
1151 Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1152 ) {
1153 match response.into_json() {
1154 Ok(DiagServiceJsonResponse {
1155 data: serde_json::Value::Object(m),
1156 errors,
1157 }) => (m, field_parse_errors_to_json(errors, "parameters")),
1158 Ok(DiagServiceJsonResponse {
1159 data: serde_json::Value::Null,
1160 ..
1161 }) => (serde_json::Map::new(), vec![]),
1162 Ok(v) => (
1163 serde_json::Map::new(),
1164 vec![err_invalid_content(format!(
1165 "Expected JSON object but got: {}",
1166 v.data
1167 ))],
1168 ),
1169 Err(e) => (
1170 serde_json::Map::new(),
1171 vec![err_invalid_content(format!(
1172 "Failed to parse {context} response: {e:?}"
1173 ))],
1174 ),
1175 }
1176 }
1177
1178 fn parse_exec_uuid(id: &str, include_schema: bool) -> Result<Uuid, ErrorWrapper> {
1179 Uuid::parse_str(id).map_err(|e| ErrorWrapper {
1180 error: ApiError::BadRequest(format!("{e:?}")),
1181 include_schema,
1182 })
1183 }
1184
1185 /// Builds the `200 OK` `AsyncGetByIdResponse` body used by both the
1186 /// `RequestResults` success path and the `suppress_service` fallback path.
1187 /// An empty `parameters` map is serialised as `null` per the SOVD spec.
1188 fn get_by_id_response(
1189 status: ExecutionStatus,
1190 parameters: serde_json::Map<String, serde_json::Value>,
1191 error: Vec<sovd_interfaces::error::DataError<VendorErrorCode>>,
1192 include_schema: bool,
1193 ) -> Response {
1194 use sovd_interfaces::components::ecu::operations::GetByIdCapability;
1195 let schema = if include_schema {
1196 Some(create_schema!(AsyncGetByIdResponse<VendorErrorCode>))
1197 } else {
1198 None
1199 };
1200 let parameters = if parameters.is_empty() {
1201 None
1202 } else {
1203 Some(parameters)
1204 };
1205 (
1206 StatusCode::OK,
1207 Json(AsyncGetByIdResponse::<VendorErrorCode> {
1208 status,
1209 capability: GetByIdCapability::Execute,
1210 parameters,
1211 progress: None,
1212 error,
1213 schema,
1214 }),
1215 )
1216 .into_response()
1217 }
1218
1219 /// Handles the async (Stop/RequestResults) POST path: finalises the
1220 /// previously reserved execution with the Start-response parameters,
1221 /// then returns 202 Accepted with only `id` and `status` per spec Table 184.
1222 async fn handle_async_post<T: UdsEcu>(
1223 response: Option<T::Response>,
1224 map_to_json: bool,
1225 include_schema: bool,
1226 base_path: String,
1227 service: String,
1228 service_executions: std::sync::Arc<
1229 tokio::sync::RwLock<
1230 cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1231 >,
1232 >,
1233 exec_id: Uuid,
1234 ) -> Response {
1235 let parameters = match response {
1236 Some(r) if map_to_json && !r.is_empty() => match r.into_json() {
1237 Ok(DiagServiceJsonResponse {
1238 data: serde_json::Value::Object(m),
1239 ..
1240 }) => m,
1241 _ => serde_json::Map::new(),
1242 },
1243 _ => serde_json::Map::new(),
1244 };
1245 finalize_execution(&service_executions, &service, &exec_id, |exec| {
1246 exec.parameters = parameters;
1247 })
1248 .await;
1249 let schema = if include_schema {
1250 Some(create_schema!(AsyncPostResponse))
1251 } else {
1252 None
1253 };
1254 (
1255 StatusCode::ACCEPTED,
1256 [(header::LOCATION, format!("{base_path}/{exec_id}"))],
1257 Json(AsyncPostResponse {
1258 id: exec_id.to_string(),
1259 status: Some(ExecutionStatus::Running),
1260 schema,
1261 }),
1262 )
1263 .into_response()
1264 }
1265
1266 /// Handles the synchronous (no Stop/RequestResults) POST path: returns 200 OK
1267 /// with the mapped response parameters, or raw bytes if the accept header
1268 /// requested octet-stream.
1269 async fn handle_sync_post<T: UdsEcu + SchemaProvider>(
1270 response: Option<T::Response>,
1271 map_to_json: bool,
1272 include_schema: bool,
1273 ecu_name: &str,
1274 uds: &T,
1275 diag_service: &DiagComm,
1276 ) -> Response {
1277 let schema = if map_to_json && include_schema {
1278 let subschema = get_subschema(ecu_name, uds, diag_service).await;
1279 Some(create_response_schema!(
1280 sovd_executions::Response<VendorErrorCode>,
1281 "parameters",
1282 subschema
1283 ))
1284 } else {
1285 None
1286 };
1287
1288 if map_to_json {
1289 let (mapped_data, parse_errors) =
1290 match response.and_then(|r| (!r.is_empty()).then(|| r.into_json())) {
1291 None => (serde_json::Map::new(), vec![]),
1292 Some(Ok(DiagServiceJsonResponse {
1293 data: serde_json::Value::Object(mapped_data),
1294 errors,
1295 })) => (mapped_data, errors),
1296 Some(Ok(v)) => {
1297 return ErrorWrapper {
1298 error: ApiError::InternalServerError(Some(format!(
1299 "Expected JSON object but got: {}",
1300 v.data
1301 ))),
1302 include_schema,
1303 }
1304 .into_response();
1305 }
1306 Some(Err(e)) => {
1307 return ErrorWrapper {
1308 error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1309 include_schema,
1310 }
1311 .into_response();
1312 }
1313 };
1314 // Spec Table 183: `error` is singular (first parse error wins).
1315 let error = field_parse_errors_to_json(parse_errors, "parameters")
1316 .into_iter()
1317 .next();
1318 let parameters = if mapped_data.is_empty() {
1319 None
1320 } else {
1321 Some(mapped_data)
1322 };
1323 (
1324 StatusCode::OK,
1325 Json(sovd_executions::Response {
1326 parameters,
1327 error,
1328 schema,
1329 }),
1330 )
1331 .into_response()
1332 } else {
1333 let data = response.map_or(vec![], |r| r.get_raw().to_vec());
1334 (StatusCode::OK, Bytes::from_owner(data)).into_response()
1335 }
1336 }
1337
1338 #[allow(
1339 clippy::too_many_lines,
1340 reason = "Current implementation has little potential to extract smaller functions"
1341 )]
1342 async fn ecu_reset_handler<T: UdsEcu + SchemaProvider + Clone>(
1343 service: String,
1344 ecu_name: &str,
1345 uds: &T,
1346 body: Bytes,
1347 security_plugin: Box<dyn SecurityPlugin>,
1348 include_schema: bool,
1349 ) -> Response {
1350 // todo: in the future we have to handle possible parameters for the reset service
1351 let Some(request_parameters) =
1352 serde_json::from_slice::<sovd_executions::Request>(&body)
1353 .ok()
1354 .and_then(|v| v.parameters)
1355 else {
1356 return ErrorWrapper {
1357 error: ApiError::BadRequest("Invalid request body".to_string()),
1358 include_schema,
1359 }
1360 .into_response();
1361 };
1362
1363 let Some(value) = request_parameters.get("value") else {
1364 return ErrorWrapper {
1365 error: ApiError::BadRequest(
1366 "Missing 'value' parameter in request body".to_owned(),
1367 ),
1368 include_schema,
1369 }
1370 .into_response();
1371 };
1372
1373 let Some(value_str) = value.as_str() else {
1374 return ErrorWrapper {
1375 error: ApiError::BadRequest(
1376 "The 'value' parameter must be a string".to_owned(),
1377 ),
1378 include_schema,
1379 }
1380 .into_response();
1381 };
1382
1383 let allowed_values = match uds.get_ecu_reset_services(ecu_name).await {
1384 Ok(v) => v,
1385 Err(e) => {
1386 return ErrorWrapper {
1387 error: e.into(),
1388 include_schema,
1389 }
1390 .into_response();
1391 }
1392 };
1393
1394 if !allowed_values
1395 .iter()
1396 .any(|v| v.eq_ignore_ascii_case(value_str))
1397 {
1398 return ErrorWrapper {
1399 error: ApiError::BadRequest(format!(
1400 "Invalid value for reset service: {value_str}. Allowed values: [{}]",
1401 allowed_values.join(", ")
1402 )),
1403 include_schema,
1404 }
1405 .into_response();
1406 }
1407
1408 let diag_service = DiagComm {
1409 name: service.clone(),
1410 type_: DiagCommType::Modes, // ecureset is in modes
1411 lookup_name: Some(value_str.to_owned()),
1412 subfunction_id: None,
1413 };
1414
1415 let schema = if include_schema {
1416 let subschema = get_subschema(ecu_name, uds, &diag_service).await;
1417 Some(create_response_schema!(
1418 sovd_executions::Response<VendorErrorCode>,
1419 "parameters",
1420 subschema
1421 ))
1422 } else {
1423 None
1424 };
1425
1426 let response = match uds
1427 .send(
1428 ecu_name,
1429 diag_service,
1430 &(security_plugin as DynamicPlugin),
1431 None,
1432 true,
1433 )
1434 .await
1435 {
1436 Ok(v) => v,
1437 Err(e) => {
1438 return ErrorWrapper {
1439 error: e.into(),
1440 include_schema,
1441 }
1442 .into_response();
1443 }
1444 };
1445
1446 match response.response_type() {
1447 DiagServiceResponseType::Negative => {
1448 api_error_from_diag_response(&response, include_schema).into_response()
1449 }
1450 DiagServiceResponseType::Positive => {
1451 if response.is_empty() {
1452 StatusCode::NO_CONTENT.into_response()
1453 } else {
1454 let (response_data, parse_errors) = match response.into_json() {
1455 Ok(DiagServiceJsonResponse {
1456 data: serde_json::Value::Object(mapped_data),
1457 errors,
1458 }) => (mapped_data, errors),
1459 Ok(DiagServiceJsonResponse {
1460 data: serde_json::Value::Null,
1461 errors,
1462 }) => {
1463 if errors.is_empty() {
1464 return StatusCode::NO_CONTENT.into_response();
1465 }
1466 (serde_json::Map::new(), errors)
1467 }
1468 Ok(v) => {
1469 return ErrorWrapper {
1470 error: ApiError::InternalServerError(Some(format!(
1471 "Expected JSON object but got: {}",
1472 v.data
1473 ))),
1474 include_schema,
1475 }
1476 .into_response();
1477 }
1478 Err(e) => {
1479 return ErrorWrapper {
1480 error: ApiError::InternalServerError(Some(format!("{e:?}"))),
1481 include_schema,
1482 }
1483 .into_response();
1484 }
1485 };
1486 let error = field_parse_errors_to_json(parse_errors, "parameters")
1487 .into_iter()
1488 .next();
1489 let parameters = if response_data.is_empty() {
1490 None
1491 } else {
1492 Some(response_data)
1493 };
1494 (
1495 StatusCode::OK,
1496 Json(sovd_executions::Response {
1497 parameters,
1498 error,
1499 schema,
1500 }),
1501 )
1502 .into_response()
1503 }
1504 }
1505 }
1506 }
1507
1508 async fn get_subschema<T: SchemaProvider>(
1509 ecu_name: &str,
1510 uds: &T,
1511 diag_service: &DiagComm,
1512 ) -> Option<schemars::Schema> {
1513 match uds
1514 .schema_for_responses(ecu_name, diag_service)
1515 .await
1516 .map(cda_interfaces::SchemaDescription::into_schema)
1517 {
1518 Ok(v) => v,
1519 Err(e) => {
1520 tracing::error!(
1521 error = ?e,
1522 diag_service = ?diag_service,
1523 "Failed to get schema for diag service"
1524 );
1525 None
1526 }
1527 }
1528 }
1529
1530 pub(crate) mod id {
1531 use super::*;
1532
1533 #[derive(serde::Deserialize, schemars::JsonSchema)]
1534 pub(crate) struct ServiceAndIdPathParam {
1535 pub service: String,
1536 pub id: String,
1537 }
1538
1539 /// Process a successful UDS `RequestResults` response: parse it, update stored
1540 /// execution state, and return the `200 OK` response body.
1541 async fn get_operations_response<T: UdsEcu>(
1542 response: T::Response,
1543 service: &str,
1544 exec_id: Uuid,
1545 service_executions: &tokio::sync::RwLock<
1546 cda_interfaces::HashMap<String, indexmap::IndexMap<Uuid, ServiceExecution>>,
1547 >,
1548 include_schema: bool,
1549 ) -> Response {
1550 if let DiagServiceResponseType::Negative = response.response_type() {
1551 tracing::warn!(
1552 exec_id = %exec_id,
1553 "RequestResults subfunction returned negative response"
1554 );
1555 return api_error_from_diag_response(&response, include_schema).into_response();
1556 }
1557
1558 let (parameters, error_list) = if response.is_empty() {
1559 (serde_json::Map::new(), vec![])
1560 } else {
1561 parse_json_response_params::<T::Response>(response, "RequestResults")
1562 };
1563
1564 if let Some(stored_mut) = service_executions
1565 .write()
1566 .await
1567 .get_mut(service)
1568 .and_then(|m| m.get_mut(&exec_id))
1569 {
1570 stored_mut.parameters.clone_from(¶meters);
1571 }
1572
1573 get_by_id_response(
1574 ExecutionStatus::Completed,
1575 parameters,
1576 error_list,
1577 include_schema,
1578 )
1579 }
1580
1581 pub(crate) async fn get<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1582 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1583 Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1584 WithRejection(Query(query), _): WithRejection<Query<OperationQuery>, ApiError>,
1585 State(WebserverEcuState {
1586 ecu_name,
1587 uds,
1588 service_executions,
1589 ..
1590 }): State<WebserverEcuState<T, U>>,
1591 ) -> Response {
1592 let include_schema = query.include_schema;
1593 let exec_id = match parse_exec_uuid(&id, include_schema) {
1594 Ok(v) => v,
1595 Err(e) => return e.into_response(),
1596 };
1597
1598 let stored = match guard_execution(
1599 &service_executions,
1600 &service,
1601 exec_id,
1602 include_schema,
1603 &format!("Execution {exec_id} is already in progress"),
1604 )
1605 .await
1606 {
1607 Ok(v) => v,
1608 Err(e) => return e.into_response(),
1609 };
1610
1611 // suppress_service: skip the UDS send, return stored state directly
1612 if query.suppress_service {
1613 if let Some(exec) = service_executions
1614 .write()
1615 .await
1616 .get_mut(&service)
1617 .and_then(|m| m.get_mut(&exec_id))
1618 {
1619 exec.in_flight = false;
1620 }
1621 return get_by_id_response(
1622 stored.status,
1623 stored.parameters,
1624 vec![],
1625 include_schema,
1626 );
1627 }
1628
1629 // Try to send RequestResults (subfunction 0x03)
1630 let diag_service = DiagComm {
1631 name: service.clone(),
1632 type_: DiagCommType::Operations,
1633 lookup_name: None,
1634 subfunction_id: Some(subfunction_ids::routine::REQUEST_RESULTS),
1635 };
1636 let uds_result = uds
1637 .send(
1638 &ecu_name,
1639 diag_service,
1640 &(security_plugin as DynamicPlugin),
1641 None,
1642 true,
1643 )
1644 .await;
1645
1646 if let Some(exec) = service_executions
1647 .write()
1648 .await
1649 .get_mut(&service)
1650 .and_then(|m| m.get_mut(&exec_id))
1651 {
1652 exec.in_flight = false;
1653 }
1654
1655 match uds_result {
1656 Err(e) => {
1657 tracing::warn!(
1658 error = ?e,
1659 service = %service,
1660 exec_id = %exec_id,
1661 "RequestResults subfunction failed"
1662 );
1663 ErrorWrapper {
1664 error: e.into(),
1665 include_schema,
1666 }
1667 .into_response()
1668 }
1669 Ok(response) => {
1670 get_operations_response::<T>(
1671 response,
1672 &service,
1673 exec_id,
1674 &service_executions,
1675 include_schema,
1676 )
1677 .await
1678 }
1679 }
1680 }
1681
1682 pub(crate) fn docs_get(op: TransformOperation) -> TransformOperation {
1683 op.description(
1684 "Get the result of an async operation execution (RequestResults subfunction)",
1685 )
1686 .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1687 res.description("Execution result retrieved successfully.")
1688 })
1689 .with(openapi::error_not_found)
1690 .with(openapi::error_bad_request)
1691 .with(openapi::error_bad_gateway)
1692 }
1693
1694 pub(crate) async fn delete<T: UdsEcu + SchemaProvider + Clone, U: FileManager>(
1695 UseApi(Secured(security_plugin), _): UseApi<Secured, ()>,
1696 Path(ServiceAndIdPathParam { service, id }): Path<ServiceAndIdPathParam>,
1697 WithRejection(Query(query), _): WithRejection<
1698 Query<OperationDeleteQuery>,
1699 ApiError,
1700 >,
1701 State(WebserverEcuState {
1702 ecu_name,
1703 uds,
1704 locks,
1705 service_executions,
1706 ..
1707 }): State<WebserverEcuState<T, U>>,
1708 ) -> Response {
1709 let include_schema = query.include_schema;
1710 let claims = security_plugin.as_auth_plugin().claims();
1711 if let Some(response) =
1712 validate_lock(&claims, &ecu_name, &locks, include_schema).await
1713 {
1714 return response;
1715 }
1716 let exec_id = match parse_exec_uuid(&id, include_schema) {
1717 Ok(v) => v,
1718 Err(e) => return e.into_response(),
1719 };
1720
1721 if let Err(e) = guard_execution(
1722 &service_executions,
1723 &service,
1724 exec_id,
1725 include_schema,
1726 &format!("Execution {exec_id} is already being stopped"),
1727 )
1728 .await
1729 {
1730 return e.into_response();
1731 }
1732
1733 let diag_service = DiagComm {
1734 name: service.clone(),
1735 type_: DiagCommType::Operations,
1736 lookup_name: None,
1737 subfunction_id: Some(subfunction_ids::routine::STOP),
1738 };
1739 let uds_result = uds
1740 .send(
1741 &ecu_name,
1742 diag_service,
1743 &(security_plugin as DynamicPlugin),
1744 None,
1745 true,
1746 )
1747 .await;
1748
1749 match uds_result {
1750 Ok(r) if matches!(r.response_type(), DiagServiceResponseType::Positive) => {
1751 if let Some(op_map) = service_executions.write().await.get_mut(&service) {
1752 op_map.shift_remove(&exec_id);
1753 }
1754 if r.is_empty() {
1755 StatusCode::NO_CONTENT.into_response()
1756 } else {
1757 let (parameters, error_list) =
1758 parse_json_response_params::<T::Response>(r, "Stop");
1759 get_by_id_response(
1760 ExecutionStatus::Stopped,
1761 parameters,
1762 error_list,
1763 include_schema,
1764 )
1765 }
1766 }
1767 Err(cda_interfaces::DiagServiceError::NotFound(_))
1768 if query.suppress_service =>
1769 {
1770 tracing::warn!(
1771 service = %service,
1772 exec_id = %exec_id,
1773 "Stop service not found (suppress_service=true), removing execution"
1774 );
1775 if let Some(op_map) = service_executions.write().await.get_mut(&service) {
1776 op_map.shift_remove(&exec_id);
1777 }
1778 StatusCode::NO_CONTENT.into_response()
1779 }
1780 result => {
1781 if let Err(e) = &result {
1782 tracing::warn!(
1783 error = ?e,
1784 service = %service,
1785 exec_id = %exec_id,
1786 "Stop subfunction failed"
1787 );
1788 } else {
1789 tracing::warn!(
1790 service = %service,
1791 exec_id = %exec_id,
1792 "Stop subfunction returned negative response"
1793 );
1794 }
1795 if query.force {
1796 if let Some(op_map) = service_executions.write().await.get_mut(&service)
1797 {
1798 op_map.shift_remove(&exec_id);
1799 }
1800 return StatusCode::NO_CONTENT.into_response();
1801 } else if let Some(exec) = service_executions
1802 .write()
1803 .await
1804 .get_mut(&service)
1805 .and_then(|m| m.get_mut(&exec_id))
1806 {
1807 // reset in_flight flag of the execution
1808 exec.in_flight = false;
1809 }
1810
1811 match result {
1812 Err(e) => ErrorWrapper {
1813 error: e.into(),
1814 include_schema,
1815 }
1816 .into_response(),
1817 Ok(r) => {
1818 api_error_from_diag_response(&r, include_schema).into_response()
1819 }
1820 }
1821 }
1822 }
1823 }
1824
1825 pub(crate) fn docs_delete(op: TransformOperation) -> TransformOperation {
1826 op.description(
1827 "Stop an async operation execution (Stop subfunction). Use \
1828 ?x-sovd2uds-force=true to remove even on ECU error.",
1829 )
1830 .response_with::<204, (), _>(|res| {
1831 res.description("Execution stopped and removed (no response data from ECU).")
1832 })
1833 .response_with::<200, Json<AsyncGetByIdResponse<VendorErrorCode>>, _>(|res| {
1834 res.description(
1835 "Execution stopped and removed. The ECU returned response data (non-spec \
1836 extension).",
1837 )
1838 })
1839 .with(openapi::error_not_found)
1840 .with(openapi::error_bad_request)
1841 .with(openapi::error_bad_gateway)
1842 .with(openapi::error_forbidden)
1843 }
1844 }
1845 }
1846}
1847
1848#[cfg(test)]
1849mod tests {
1850 use sovd_interfaces::components::ecu::operations::comparams::ComParamSimpleValue;
1851
1852 #[test]
1853 #[allow(clippy::float_cmp, reason = "Test verifies exact deserialized values")]
1854 fn com_param_simple_deserialization() {
1855 let json_data_string = "\"example_value\"";
1856 let deserialized_string: ComParamSimpleValue =
1857 serde_json::from_str(json_data_string).unwrap();
1858 assert_eq!(deserialized_string.value, "example_value");
1859 assert!(deserialized_string.unit.is_none());
1860
1861 let json_data_struct = r#"{
1862 "value": "test",
1863 "unit": {
1864 "factor_to_si_unit": 1.0,
1865 "offset_to_si_unit": 0.0
1866 }
1867 }"#;
1868 let deserialized_struct: ComParamSimpleValue =
1869 serde_json::from_str(json_data_struct).unwrap();
1870 assert_eq!(deserialized_struct.value, "test");
1871 let unit = deserialized_struct.unit.unwrap();
1872 assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
1873 assert_eq!(unit.offset_to_si_unit.unwrap(), 0.0);
1874
1875 let json_data_struct = r#"{
1876 "value": "test",
1877 "unit": {
1878 "factor_to_si_unit": 1.0
1879 }
1880 }"#;
1881 let deserialized_struct: ComParamSimpleValue =
1882 serde_json::from_str(json_data_struct).unwrap();
1883 assert_eq!(deserialized_struct.value, "test");
1884 let unit = deserialized_struct.unit.unwrap();
1885 assert_eq!(unit.factor_to_si_unit.unwrap(), 1.0);
1886 assert!(unit.offset_to_si_unit.is_none());
1887
1888 let json_data_struct = r#"{"value": "test"}"#;
1889 let deserialized_struct: ComParamSimpleValue =
1890 serde_json::from_str(json_data_struct).unwrap();
1891 assert_eq!(deserialized_struct.value, "test");
1892 assert!(deserialized_struct.unit.is_none());
1893
1894 let json_data_struct = r#"{"unit": {"factor_to_si_unit": 1.0}}"#;
1895 assert!(serde_json::from_str::<ComParamSimpleValue>(json_data_struct).is_err());
1896 }
1897
1898 mod ecu_operations_collection {
1899 use aide::UseApi;
1900 use axum::{extract::State, http::StatusCode};
1901 use axum_extra::extract::WithRejection;
1902 use cda_interfaces::{
1903 datatypes::ComponentOperationsInfo, file_manager::mock::MockFileManager,
1904 mock::MockUdsEcu,
1905 };
1906 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
1907 use sovd_interfaces::components::ecu::operations::OperationCollectionItem;
1908
1909 use super::super::*;
1910 use crate::sovd::tests::create_test_webserver_state;
1911
1912 #[tokio::test]
1913 async fn test_get_operations_returns_empty_list() {
1914 let ecu_name = "TestECU".to_string();
1915 let mut mock_uds = MockUdsEcu::new();
1916 let mock_file_manager = MockFileManager::new();
1917
1918 mock_uds
1919 .expect_get_components_operations_info()
1920 .withf(|ecu, _| ecu == "TestECU")
1921 .times(1)
1922 .returning(|_, _| Ok(vec![]));
1923
1924 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
1925 ecu_name,
1926 mock_uds,
1927 mock_file_manager,
1928 );
1929
1930 let response = get::<MockUdsEcu, MockFileManager>(
1931 UseApi(
1932 Secured(Box::new(TestSecurityPlugin)),
1933 std::marker::PhantomData,
1934 ),
1935 WithRejection(
1936 axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
1937 include_schema: false,
1938 }),
1939 std::marker::PhantomData,
1940 ),
1941 State(state),
1942 )
1943 .await;
1944
1945 assert_eq!(response.status(), StatusCode::OK);
1946 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
1947 .await
1948 .unwrap();
1949 let result: sovd_interfaces::Items<OperationCollectionItem> =
1950 serde_json::from_slice(&body).unwrap();
1951 assert!(result.items.is_empty());
1952 assert!(result.schema.is_none());
1953 }
1954
1955 #[tokio::test]
1956 async fn test_get_operations_returns_items() {
1957 let ecu_name = "TestECU".to_string();
1958 let mut mock_uds = MockUdsEcu::new();
1959 let mock_file_manager = MockFileManager::new();
1960
1961 mock_uds
1962 .expect_get_components_operations_info()
1963 .withf(|ecu, _| ecu == "TestECU")
1964 .times(1)
1965 .returning(|_, _| {
1966 Ok(vec![
1967 ComponentOperationsInfo {
1968 id: "CalibrateSensor".to_string(),
1969 name: "Calibrate Sensor".to_string(),
1970 has_stop: true,
1971 has_request_results: true,
1972 },
1973 ComponentOperationsInfo {
1974 id: "RunSelfTest".to_string(),
1975 name: "Run Self Test".to_string(),
1976 has_stop: false,
1977 has_request_results: false,
1978 },
1979 ])
1980 });
1981
1982 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
1983 ecu_name,
1984 mock_uds,
1985 mock_file_manager,
1986 );
1987
1988 let response = get::<MockUdsEcu, MockFileManager>(
1989 UseApi(
1990 Secured(Box::new(TestSecurityPlugin)),
1991 std::marker::PhantomData,
1992 ),
1993 WithRejection(
1994 axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
1995 include_schema: false,
1996 }),
1997 std::marker::PhantomData,
1998 ),
1999 State(state),
2000 )
2001 .await;
2002
2003 assert_eq!(response.status(), StatusCode::OK);
2004 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2005 .await
2006 .unwrap();
2007 let result: sovd_interfaces::Items<OperationCollectionItem> =
2008 serde_json::from_slice(&body).unwrap();
2009 assert_eq!(result.items.len(), 2);
2010
2011 let first = result.items.first().expect("Expected at least one item");
2012 assert_eq!(first.id, "CalibrateSensor");
2013 assert_eq!(first.name, "Calibrate Sensor");
2014 assert!(!first.proximity_proof_required);
2015 assert!(first.asynchronous_execution);
2016
2017 let second = result.items.get(1).expect("Expected a second item");
2018 assert_eq!(second.id, "RunSelfTest");
2019 assert!(!second.proximity_proof_required);
2020 assert!(!second.asynchronous_execution);
2021 }
2022
2023 #[tokio::test]
2024 async fn test_get_operations_with_schema() {
2025 let ecu_name = "TestECU".to_string();
2026 let mut mock_uds = MockUdsEcu::new();
2027 let mock_file_manager = MockFileManager::new();
2028
2029 mock_uds
2030 .expect_get_components_operations_info()
2031 .times(1)
2032 .returning(|_, _| Ok(vec![]));
2033
2034 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2035 ecu_name,
2036 mock_uds,
2037 mock_file_manager,
2038 );
2039
2040 let response = get::<MockUdsEcu, MockFileManager>(
2041 UseApi(
2042 Secured(Box::new(TestSecurityPlugin)),
2043 std::marker::PhantomData,
2044 ),
2045 WithRejection(
2046 axum::extract::Query(sovd_interfaces::IncludeSchemaQuery {
2047 include_schema: true,
2048 }),
2049 std::marker::PhantomData,
2050 ),
2051 State(state),
2052 )
2053 .await;
2054
2055 assert_eq!(response.status(), StatusCode::OK);
2056 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2057 .await
2058 .unwrap();
2059 let result: sovd_interfaces::Items<OperationCollectionItem> =
2060 serde_json::from_slice(&body).unwrap();
2061 assert!(
2062 result.schema.is_some(),
2063 "Schema should be included when requested"
2064 );
2065 }
2066 }
2067
2068 mod service_executions {
2069 use std::sync::Arc;
2070
2071 use aide::UseApi;
2072 use axum::{
2073 extract::{Path, Query, State},
2074 http::StatusCode,
2075 };
2076 use axum_extra::extract::WithRejection;
2077 use cda_interfaces::{
2078 DiagCommType, DiagServiceError,
2079 diagservices::{
2080 DiagServiceJsonResponse, DiagServiceResponseType, mock::MockDiagServiceResponse,
2081 },
2082 file_manager::mock::MockFileManager,
2083 mock::MockUdsEcu,
2084 };
2085 use cda_plugin_security::{Secured, mock::TestSecurityPlugin};
2086 use indexmap::IndexMap;
2087 use sovd_interfaces::{
2088 common::operations::OperationIdItem, components::ecu::operations::ExecutionStatus,
2089 };
2090
2091 use super::super::service::{executions as handlers, executions::id as id_handlers};
2092 use crate::sovd::{
2093 ServiceExecution, locks::insert_test_ecu_lock, tests::create_test_webserver_state,
2094 };
2095
2096 fn make_json_response(data: serde_json::Value) -> MockDiagServiceResponse {
2097 let mut resp = MockDiagServiceResponse::new();
2098 resp.expect_response_type()
2099 .returning(|| DiagServiceResponseType::Positive);
2100 resp.expect_is_empty().returning(|| false);
2101 resp.expect_into_json().return_once(move || {
2102 Ok(DiagServiceJsonResponse {
2103 data,
2104 errors: vec![],
2105 })
2106 });
2107 resp
2108 }
2109
2110 fn make_empty_positive_response() -> MockDiagServiceResponse {
2111 let mut resp = MockDiagServiceResponse::new();
2112 resp.expect_response_type()
2113 .returning(|| DiagServiceResponseType::Positive);
2114 resp.expect_is_empty().returning(|| true);
2115 resp
2116 }
2117
2118 fn make_negative_response() -> MockDiagServiceResponse {
2119 let mut resp = MockDiagServiceResponse::new();
2120 resp.expect_response_type()
2121 .returning(|| DiagServiceResponseType::Negative);
2122 resp.expect_is_empty().returning(|| false);
2123 resp.expect_as_nrc().returning(|| {
2124 Ok(cda_interfaces::diagservices::MappedNRC {
2125 code: Some(0x22),
2126 description: Some("conditionsNotCorrect".to_string()),
2127 sid: None,
2128 })
2129 });
2130 resp
2131 }
2132
2133 #[tokio::test]
2134 async fn test_list_executions_empty() {
2135 let ecu_name = "TestECU".to_string();
2136 let mock_uds = MockUdsEcu::new();
2137 let mock_file_manager = MockFileManager::new();
2138 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2139 ecu_name,
2140 mock_uds,
2141 mock_file_manager,
2142 );
2143
2144 let response = handlers::get::<MockUdsEcu, MockFileManager>(
2145 UseApi(
2146 Secured(Box::new(TestSecurityPlugin)),
2147 std::marker::PhantomData,
2148 ),
2149 Path(handlers::OperationServicePathParam {
2150 service: "CalibrateSensor".to_string(),
2151 }),
2152 WithRejection(
2153 Query(
2154 sovd_interfaces::components::ecu::operations::service::executions::Query {
2155 include_schema: false,
2156 },
2157 ),
2158 std::marker::PhantomData,
2159 ),
2160 State(state),
2161 )
2162 .await;
2163
2164 assert_eq!(response.status(), StatusCode::OK);
2165 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2166 .await
2167 .unwrap();
2168 let result: sovd_interfaces::Items<OperationIdItem> =
2169 serde_json::from_slice(&body).unwrap();
2170 assert!(result.items.is_empty());
2171 }
2172
2173 #[tokio::test]
2174 async fn test_list_executions_shows_tracked_id() {
2175 let ecu_name = "TestECU".to_string();
2176 let mock_uds = MockUdsEcu::new();
2177 let mock_file_manager = MockFileManager::new();
2178 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2179 ecu_name,
2180 mock_uds,
2181 mock_file_manager,
2182 );
2183
2184 // Pre-populate an execution
2185 let exec_id = uuid::Uuid::new_v4();
2186 state
2187 .service_executions
2188 .write()
2189 .await
2190 .entry("CalibrateSensor".to_string())
2191 .or_default()
2192 .insert(
2193 exec_id,
2194 ServiceExecution {
2195 parameters: serde_json::Map::new(),
2196 status: ExecutionStatus::Running,
2197 in_flight: false,
2198 is_created: true,
2199 },
2200 );
2201
2202 let response = handlers::get::<MockUdsEcu, MockFileManager>(
2203 UseApi(
2204 Secured(Box::new(TestSecurityPlugin)),
2205 std::marker::PhantomData,
2206 ),
2207 Path(handlers::OperationServicePathParam {
2208 service: "CalibrateSensor".to_string(),
2209 }),
2210 WithRejection(
2211 Query(
2212 sovd_interfaces::components::ecu::operations::service::executions::Query {
2213 include_schema: false,
2214 },
2215 ),
2216 std::marker::PhantomData,
2217 ),
2218 State(state),
2219 )
2220 .await;
2221
2222 assert_eq!(response.status(), StatusCode::OK);
2223 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2224 .await
2225 .unwrap();
2226 let result: sovd_interfaces::Items<OperationIdItem> =
2227 serde_json::from_slice(&body).unwrap();
2228 assert_eq!(result.items.len(), 1);
2229 assert_eq!(
2230 result.items.first().expect("Expected at least one item").id,
2231 exec_id.to_string()
2232 );
2233 }
2234
2235 #[tokio::test]
2236 async fn test_get_execution_by_id_not_found() {
2237 let ecu_name = "TestECU".to_string();
2238 let mock_uds = MockUdsEcu::new();
2239 let mock_file_manager = MockFileManager::new();
2240 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2241 ecu_name,
2242 mock_uds,
2243 mock_file_manager,
2244 );
2245
2246 let unknown_id = uuid::Uuid::new_v4().to_string();
2247 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2248 UseApi(
2249 Secured(Box::new(TestSecurityPlugin)),
2250 std::marker::PhantomData,
2251 ),
2252 Path(id_handlers::ServiceAndIdPathParam {
2253 service: "CalibrateSensor".to_string(),
2254 id: unknown_id,
2255 }),
2256 WithRejection(
2257 Query(
2258 sovd_interfaces::components::ecu::operations::OperationQuery {
2259 include_schema: false,
2260 suppress_service: false,
2261 },
2262 ),
2263 std::marker::PhantomData,
2264 ),
2265 State(state),
2266 )
2267 .await;
2268
2269 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2270 }
2271
2272 #[tokio::test]
2273 async fn test_get_execution_by_id_calls_request_results() {
2274 let ecu_name = "TestECU".to_string();
2275 let mut mock_uds = MockUdsEcu::new();
2276 let mock_file_manager = MockFileManager::new();
2277
2278 // Expect send with subfunction_id = REQUEST_RESULTS (0x03)
2279 mock_uds
2280 .expect_send()
2281 .withf(|ecu, service, _, payload, map_to_json| {
2282 ecu == "TestECU"
2283 && service.type_ == DiagCommType::Operations
2284 && service.subfunction_id
2285 == Some(cda_interfaces::subfunction_ids::routine::REQUEST_RESULTS)
2286 && service.lookup_name.is_none()
2287 && payload.is_none()
2288 && *map_to_json
2289 })
2290 .times(1)
2291 .returning(|_, _, _, _, _| {
2292 Ok(make_json_response(serde_json::json!({
2293 "result": "ok"
2294 })))
2295 });
2296
2297 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2298 ecu_name,
2299 mock_uds,
2300 mock_file_manager,
2301 );
2302
2303 let exec_id = uuid::Uuid::new_v4();
2304 state
2305 .service_executions
2306 .write()
2307 .await
2308 .entry("CalibrateSensor".to_string())
2309 .or_default()
2310 .insert(
2311 exec_id,
2312 ServiceExecution {
2313 parameters: serde_json::Map::new(),
2314 status: ExecutionStatus::Running,
2315 in_flight: false,
2316 is_created: true,
2317 },
2318 );
2319
2320 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2321 UseApi(
2322 Secured(Box::new(TestSecurityPlugin)),
2323 std::marker::PhantomData,
2324 ),
2325 Path(id_handlers::ServiceAndIdPathParam {
2326 service: "CalibrateSensor".to_string(),
2327 id: exec_id.to_string(),
2328 }),
2329 WithRejection(
2330 Query(
2331 sovd_interfaces::components::ecu::operations::OperationQuery {
2332 include_schema: false,
2333 suppress_service: false,
2334 },
2335 ),
2336 std::marker::PhantomData,
2337 ),
2338 State(state),
2339 )
2340 .await;
2341
2342 assert_eq!(response.status(), StatusCode::OK);
2343 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2344 .await
2345 .unwrap();
2346 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2347 assert_eq!(
2348 result.get("status").expect("missing status"),
2349 &serde_json::json!("completed")
2350 );
2351 assert_eq!(
2352 result.get("capability").expect("missing capability"),
2353 &serde_json::json!("execute")
2354 );
2355 assert_eq!(
2356 result
2357 .get("parameters")
2358 .expect("missing parameters")
2359 .get("result")
2360 .expect("missing result"),
2361 &serde_json::json!("ok")
2362 );
2363 }
2364
2365 #[tokio::test]
2366 async fn test_get_execution_suppress_service_skips_send_returns_stored() {
2367 let ecu_name = "TestECU".to_string();
2368 let mut mock_uds = MockUdsEcu::new();
2369 let mock_file_manager = MockFileManager::new();
2370
2371 // suppress_service=true must skip the UDS send entirely
2372 mock_uds.expect_send().times(0);
2373
2374 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2375 ecu_name,
2376 mock_uds,
2377 mock_file_manager,
2378 );
2379
2380 let exec_id = uuid::Uuid::new_v4();
2381 let stored_params = {
2382 let mut m = serde_json::Map::new();
2383 m.insert("stored".to_string(), serde_json::json!("value"));
2384 m
2385 };
2386 state
2387 .service_executions
2388 .write()
2389 .await
2390 .entry("CalibrateSensor".to_string())
2391 .or_default()
2392 .insert(
2393 exec_id,
2394 ServiceExecution {
2395 parameters: stored_params,
2396 status: ExecutionStatus::Running,
2397 in_flight: false,
2398 is_created: true,
2399 },
2400 );
2401
2402 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2403 UseApi(
2404 Secured(Box::new(TestSecurityPlugin)),
2405 std::marker::PhantomData,
2406 ),
2407 Path(id_handlers::ServiceAndIdPathParam {
2408 service: "CalibrateSensor".to_string(),
2409 id: exec_id.to_string(),
2410 }),
2411 WithRejection(
2412 Query(
2413 sovd_interfaces::components::ecu::operations::OperationQuery {
2414 include_schema: false,
2415 suppress_service: true,
2416 },
2417 ),
2418 std::marker::PhantomData,
2419 ),
2420 State(state),
2421 )
2422 .await;
2423
2424 // suppress_service=true -> should return 200 with stored params, no UDS send
2425 assert_eq!(response.status(), StatusCode::OK);
2426 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2427 .await
2428 .unwrap();
2429 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2430 assert_eq!(
2431 result.get("status").expect("missing status"),
2432 &serde_json::json!("running")
2433 );
2434 assert_eq!(
2435 result
2436 .get("parameters")
2437 .expect("missing parameters")
2438 .get("stored")
2439 .expect("missing stored"),
2440 &serde_json::json!("value")
2441 );
2442 }
2443
2444 #[tokio::test]
2445 async fn test_get_execution_not_found_without_suppress_returns_error() {
2446 let ecu_name = "TestECU".to_string();
2447 let mut mock_uds = MockUdsEcu::new();
2448 let mock_file_manager = MockFileManager::new();
2449
2450 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
2451 Err(DiagServiceError::NotFound(
2452 "CalibrateSensor_RequestResults not found".to_string(),
2453 ))
2454 });
2455
2456 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2457 ecu_name,
2458 mock_uds,
2459 mock_file_manager,
2460 );
2461
2462 let exec_id = uuid::Uuid::new_v4();
2463 state
2464 .service_executions
2465 .write()
2466 .await
2467 .entry("CalibrateSensor".to_string())
2468 .or_default()
2469 .insert(
2470 exec_id,
2471 ServiceExecution {
2472 parameters: serde_json::Map::new(),
2473 status: ExecutionStatus::Running,
2474 in_flight: false,
2475 is_created: true,
2476 },
2477 );
2478
2479 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
2480 UseApi(
2481 Secured(Box::new(TestSecurityPlugin)),
2482 std::marker::PhantomData,
2483 ),
2484 Path(id_handlers::ServiceAndIdPathParam {
2485 service: "CalibrateSensor".to_string(),
2486 id: exec_id.to_string(),
2487 }),
2488 WithRejection(
2489 Query(
2490 sovd_interfaces::components::ecu::operations::OperationQuery {
2491 include_schema: false,
2492 suppress_service: false,
2493 },
2494 ),
2495 std::marker::PhantomData,
2496 ),
2497 State(state),
2498 )
2499 .await;
2500
2501 // suppress_service=false -> NotFound from UDS should propagate as error
2502 assert!(response.status().is_client_error() || response.status().is_server_error());
2503 }
2504
2505 #[tokio::test]
2506 async fn test_delete_execution_not_found() {
2507 let ecu_name = "TestECU".to_string();
2508 let mock_uds = MockUdsEcu::new();
2509 let mock_file_manager = MockFileManager::new();
2510 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2511 ecu_name.clone(),
2512 mock_uds,
2513 mock_file_manager,
2514 );
2515 insert_test_ecu_lock(&state.locks, &ecu_name).await;
2516
2517 let unknown_id = uuid::Uuid::new_v4().to_string();
2518 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2519 UseApi(
2520 Secured(Box::new(TestSecurityPlugin)),
2521 std::marker::PhantomData,
2522 ),
2523 Path(id_handlers::ServiceAndIdPathParam {
2524 service: "CalibrateSensor".to_string(),
2525 id: unknown_id,
2526 }),
2527 WithRejection(
2528 Query(
2529 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2530 include_schema: false,
2531 suppress_service: false,
2532 force: false,
2533 },
2534 ),
2535 std::marker::PhantomData,
2536 ),
2537 State(state),
2538 )
2539 .await;
2540
2541 assert_eq!(response.status(), StatusCode::NOT_FOUND);
2542 }
2543
2544 #[tokio::test]
2545 async fn test_delete_execution_calls_stop() {
2546 let ecu_name = "TestECU".to_string();
2547 let mut mock_uds = MockUdsEcu::new();
2548 let mock_file_manager = MockFileManager::new();
2549
2550 // Expect send with subfunction_id = STOP (0x02)
2551 mock_uds
2552 .expect_send()
2553 .withf(|ecu, service, _, _, _| {
2554 ecu == "TestECU"
2555 && service.type_ == DiagCommType::Operations
2556 && service.subfunction_id
2557 == Some(cda_interfaces::subfunction_ids::routine::STOP)
2558 && service.lookup_name.is_none()
2559 })
2560 .times(1)
2561 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
2562
2563 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2564 ecu_name,
2565 mock_uds,
2566 mock_file_manager,
2567 );
2568 insert_test_ecu_lock(&state.locks, "TestECU").await;
2569
2570 let exec_id = uuid::Uuid::new_v4();
2571 state
2572 .service_executions
2573 .write()
2574 .await
2575 .entry("CalibrateSensor".to_string())
2576 .or_default()
2577 .insert(
2578 exec_id,
2579 ServiceExecution {
2580 parameters: serde_json::Map::new(),
2581 status: ExecutionStatus::Running,
2582 in_flight: false,
2583 is_created: true,
2584 },
2585 );
2586
2587 // Keep a reference to service_executions so we can verify after consuming state
2588 let service_executions_ref = Arc::clone(&state.service_executions);
2589
2590 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2591 UseApi(
2592 Secured(Box::new(TestSecurityPlugin)),
2593 std::marker::PhantomData,
2594 ),
2595 Path(id_handlers::ServiceAndIdPathParam {
2596 service: "CalibrateSensor".to_string(),
2597 id: exec_id.to_string(),
2598 }),
2599 WithRejection(
2600 Query(
2601 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2602 include_schema: false,
2603 suppress_service: false,
2604 force: false,
2605 },
2606 ),
2607 std::marker::PhantomData,
2608 ),
2609 State(state),
2610 )
2611 .await;
2612
2613 assert_eq!(response.status(), StatusCode::NO_CONTENT);
2614 // Verify execution was removed
2615 assert!(
2616 service_executions_ref
2617 .read()
2618 .await
2619 .values()
2620 .all(IndexMap::is_empty)
2621 );
2622 }
2623
2624 #[tokio::test]
2625 async fn test_delete_execution_stop_with_data_returns_200_stopped() {
2626 let ecu_name = "TestECU".to_string();
2627 let mut mock_uds = MockUdsEcu::new();
2628 let mock_file_manager = MockFileManager::new();
2629
2630 // ECU returns a non-empty positive response from Stop
2631 mock_uds
2632 .expect_send()
2633 .withf(|ecu, service, _, _, map_to_json| {
2634 ecu == "TestECU"
2635 && service.subfunction_id
2636 == Some(cda_interfaces::subfunction_ids::routine::STOP)
2637 && *map_to_json
2638 })
2639 .times(1)
2640 .returning(|_, _, _, _, _| {
2641 Ok(make_json_response(serde_json::json!({
2642 "stop_result": "ok"
2643 })))
2644 });
2645
2646 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2647 ecu_name,
2648 mock_uds,
2649 mock_file_manager,
2650 );
2651 insert_test_ecu_lock(&state.locks, "TestECU").await;
2652
2653 let exec_id = uuid::Uuid::new_v4();
2654 state
2655 .service_executions
2656 .write()
2657 .await
2658 .entry("CalibrateSensor".to_string())
2659 .or_default()
2660 .insert(
2661 exec_id,
2662 ServiceExecution {
2663 parameters: serde_json::Map::new(),
2664 status: ExecutionStatus::Running,
2665 in_flight: false,
2666 is_created: true,
2667 },
2668 );
2669
2670 let service_executions_ref = Arc::clone(&state.service_executions);
2671
2672 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2673 UseApi(
2674 Secured(Box::new(TestSecurityPlugin)),
2675 std::marker::PhantomData,
2676 ),
2677 Path(id_handlers::ServiceAndIdPathParam {
2678 service: "CalibrateSensor".to_string(),
2679 id: exec_id.to_string(),
2680 }),
2681 WithRejection(
2682 Query(
2683 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2684 include_schema: false,
2685 suppress_service: false,
2686 force: false,
2687 },
2688 ),
2689 std::marker::PhantomData,
2690 ),
2691 State(state),
2692 )
2693 .await;
2694
2695 // ECU returned data -> 200 with status=stopped and the parameters
2696 assert_eq!(response.status(), StatusCode::OK);
2697 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2698 .await
2699 .unwrap();
2700 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2701 assert_eq!(
2702 result.get("status").expect("missing status"),
2703 &serde_json::json!("stopped")
2704 );
2705 assert_eq!(
2706 result
2707 .get("parameters")
2708 .expect("missing parameters")
2709 .get("stop_result")
2710 .expect("missing stop_result"),
2711 &serde_json::json!("ok")
2712 );
2713 // Execution must be removed regardless
2714 assert!(
2715 service_executions_ref
2716 .read()
2717 .await
2718 .values()
2719 .all(IndexMap::is_empty)
2720 );
2721 }
2722
2723 #[tokio::test]
2724 async fn test_delete_execution_stop_with_null_json_returns_200_empty_parameters() {
2725 // Stop maps to JSON Null -> 200 with empty parameters (user-requested extension)
2726 let ecu_name = "TestECU".to_string();
2727 let mut mock_uds = MockUdsEcu::new();
2728 let mock_file_manager = MockFileManager::new();
2729
2730 mock_uds
2731 .expect_send()
2732 .withf(|ecu, service, _, _, map_to_json| {
2733 ecu == "TestECU"
2734 && service.subfunction_id
2735 == Some(cda_interfaces::subfunction_ids::routine::STOP)
2736 && *map_to_json
2737 })
2738 .times(1)
2739 .returning(|_, _, _, _, _| Ok(make_json_response(serde_json::Value::Null)));
2740
2741 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2742 ecu_name,
2743 mock_uds,
2744 mock_file_manager,
2745 );
2746 insert_test_ecu_lock(&state.locks, "TestECU").await;
2747
2748 let exec_id = uuid::Uuid::new_v4();
2749 state
2750 .service_executions
2751 .write()
2752 .await
2753 .entry("CalibrateSensor".to_string())
2754 .or_default()
2755 .insert(
2756 exec_id,
2757 ServiceExecution {
2758 parameters: serde_json::Map::new(),
2759 status: ExecutionStatus::Running,
2760 in_flight: false,
2761 is_created: true,
2762 },
2763 );
2764
2765 let service_executions_ref = Arc::clone(&state.service_executions);
2766
2767 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2768 UseApi(
2769 Secured(Box::new(TestSecurityPlugin)),
2770 std::marker::PhantomData,
2771 ),
2772 Path(id_handlers::ServiceAndIdPathParam {
2773 service: "CalibrateSensor".to_string(),
2774 id: exec_id.to_string(),
2775 }),
2776 WithRejection(
2777 Query(
2778 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2779 include_schema: false,
2780 suppress_service: false,
2781 force: false,
2782 },
2783 ),
2784 std::marker::PhantomData,
2785 ),
2786 State(state),
2787 )
2788 .await;
2789
2790 assert_eq!(response.status(), StatusCode::OK);
2791 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2792 .await
2793 .unwrap();
2794 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2795 assert_eq!(
2796 result.get("status").expect("missing status"),
2797 &serde_json::json!("stopped")
2798 );
2799 // parameters: None when empty -> field is omitted from JSON
2800 assert!(
2801 result.get("parameters").is_none(),
2802 "parameters should be absent when Stop returns Null"
2803 );
2804 assert!(
2805 service_executions_ref
2806 .read()
2807 .await
2808 .values()
2809 .all(IndexMap::is_empty)
2810 );
2811 }
2812
2813 #[tokio::test]
2814 async fn test_delete_execution_stop_non_object_json_returns_200_stopped_with_error() {
2815 // Stop maps to a non-object JSON value (e.g. a string) -> 200 stopped, error surfaced
2816 let ecu_name = "TestECU".to_string();
2817 let mut mock_uds = MockUdsEcu::new();
2818 let mock_file_manager = MockFileManager::new();
2819
2820 mock_uds
2821 .expect_send()
2822 .withf(|ecu, service, _, _, map_to_json| {
2823 ecu == "TestECU"
2824 && service.subfunction_id
2825 == Some(cda_interfaces::subfunction_ids::routine::STOP)
2826 && *map_to_json
2827 })
2828 .times(1)
2829 .returning(|_, _, _, _, _| {
2830 Ok(make_json_response(serde_json::json!("unexpected_string")))
2831 });
2832
2833 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2834 ecu_name,
2835 mock_uds,
2836 mock_file_manager,
2837 );
2838 insert_test_ecu_lock(&state.locks, "TestECU").await;
2839
2840 let exec_id = uuid::Uuid::new_v4();
2841 state
2842 .service_executions
2843 .write()
2844 .await
2845 .entry("CalibrateSensor".to_string())
2846 .or_default()
2847 .insert(
2848 exec_id,
2849 ServiceExecution {
2850 parameters: serde_json::Map::new(),
2851 status: ExecutionStatus::Running,
2852 in_flight: false,
2853 is_created: true,
2854 },
2855 );
2856
2857 let service_executions_ref = Arc::clone(&state.service_executions);
2858
2859 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2860 UseApi(
2861 Secured(Box::new(TestSecurityPlugin)),
2862 std::marker::PhantomData,
2863 ),
2864 Path(id_handlers::ServiceAndIdPathParam {
2865 service: "CalibrateSensor".to_string(),
2866 id: exec_id.to_string(),
2867 }),
2868 WithRejection(
2869 Query(
2870 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2871 include_schema: false,
2872 suppress_service: false,
2873 force: false,
2874 },
2875 ),
2876 std::marker::PhantomData,
2877 ),
2878 State(state),
2879 )
2880 .await;
2881
2882 assert_eq!(response.status(), StatusCode::OK);
2883 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2884 .await
2885 .unwrap();
2886 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2887 assert_eq!(
2888 result.get("status").expect("missing status"),
2889 &serde_json::json!("stopped")
2890 );
2891 // error list (field name "error" per AsyncGetByIdResponse) must be non-empty
2892 let errors = result.get("error").expect("missing error field");
2893 assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
2894 assert!(
2895 service_executions_ref
2896 .read()
2897 .await
2898 .values()
2899 .all(IndexMap::is_empty)
2900 );
2901 }
2902
2903 #[tokio::test]
2904 async fn test_delete_execution_stop_into_json_error_returns_200_stopped_with_error() {
2905 // Stop response cannot be parsed (into_json fails) -> 200 stopped, error surfaced
2906 let ecu_name = "TestECU".to_string();
2907 let mut mock_uds = MockUdsEcu::new();
2908 let mock_file_manager = MockFileManager::new();
2909
2910 mock_uds
2911 .expect_send()
2912 .withf(|ecu, service, _, _, map_to_json| {
2913 ecu == "TestECU"
2914 && service.subfunction_id
2915 == Some(cda_interfaces::subfunction_ids::routine::STOP)
2916 && *map_to_json
2917 })
2918 .times(1)
2919 .returning(|_, _, _, _, _| {
2920 let mut resp = MockDiagServiceResponse::new();
2921 resp.expect_response_type()
2922 .returning(|| DiagServiceResponseType::Positive);
2923 resp.expect_is_empty().returning(|| false);
2924 resp.expect_into_json().return_once(|| {
2925 Err(DiagServiceError::BadPayload(
2926 "simulated Stop parse failure".to_string(),
2927 ))
2928 });
2929 Ok(resp)
2930 });
2931
2932 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
2933 ecu_name,
2934 mock_uds,
2935 mock_file_manager,
2936 );
2937 insert_test_ecu_lock(&state.locks, "TestECU").await;
2938
2939 let exec_id = uuid::Uuid::new_v4();
2940 state
2941 .service_executions
2942 .write()
2943 .await
2944 .entry("CalibrateSensor".to_string())
2945 .or_default()
2946 .insert(
2947 exec_id,
2948 ServiceExecution {
2949 parameters: serde_json::Map::new(),
2950 status: ExecutionStatus::Running,
2951 in_flight: false,
2952 is_created: true,
2953 },
2954 );
2955
2956 let service_executions_ref = Arc::clone(&state.service_executions);
2957
2958 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
2959 UseApi(
2960 Secured(Box::new(TestSecurityPlugin)),
2961 std::marker::PhantomData,
2962 ),
2963 Path(id_handlers::ServiceAndIdPathParam {
2964 service: "CalibrateSensor".to_string(),
2965 id: exec_id.to_string(),
2966 }),
2967 WithRejection(
2968 Query(
2969 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
2970 include_schema: false,
2971 suppress_service: false,
2972 force: false,
2973 },
2974 ),
2975 std::marker::PhantomData,
2976 ),
2977 State(state),
2978 )
2979 .await;
2980
2981 assert_eq!(response.status(), StatusCode::OK);
2982 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
2983 .await
2984 .unwrap();
2985 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
2986 assert_eq!(
2987 result.get("status").expect("missing status"),
2988 &serde_json::json!("stopped")
2989 );
2990 let errors = result.get("error").expect("missing error field");
2991 assert!(errors.is_array() && !errors.as_array().unwrap().is_empty());
2992 assert!(
2993 service_executions_ref
2994 .read()
2995 .await
2996 .values()
2997 .all(IndexMap::is_empty)
2998 );
2999 }
3000
3001 #[tokio::test]
3002 async fn test_delete_execution_force_removes_on_uds_error() {
3003 let ecu_name = "TestECU".to_string();
3004 let mut mock_uds = MockUdsEcu::new();
3005 let mock_file_manager = MockFileManager::new();
3006
3007 // UDS returns an error (non-NotFound)
3008 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3009 Err(DiagServiceError::SendFailed("timeout".to_string()))
3010 });
3011
3012 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3013 ecu_name,
3014 mock_uds,
3015 mock_file_manager,
3016 );
3017 insert_test_ecu_lock(&state.locks, "TestECU").await;
3018
3019 let exec_id = uuid::Uuid::new_v4();
3020 state
3021 .service_executions
3022 .write()
3023 .await
3024 .entry("CalibrateSensor".to_string())
3025 .or_default()
3026 .insert(
3027 exec_id,
3028 ServiceExecution {
3029 parameters: serde_json::Map::new(),
3030 status: ExecutionStatus::Running,
3031 in_flight: false,
3032 is_created: true,
3033 },
3034 );
3035
3036 // Keep a reference to service_executions so we can verify after consuming state
3037 let service_executions_ref = Arc::clone(&state.service_executions);
3038
3039 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3040 UseApi(
3041 Secured(Box::new(TestSecurityPlugin)),
3042 std::marker::PhantomData,
3043 ),
3044 Path(id_handlers::ServiceAndIdPathParam {
3045 service: "CalibrateSensor".to_string(),
3046 id: exec_id.to_string(),
3047 }),
3048 WithRejection(
3049 Query(
3050 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3051 include_schema: false,
3052 suppress_service: false,
3053 force: true,
3054 },
3055 ),
3056 std::marker::PhantomData,
3057 ),
3058 State(state),
3059 )
3060 .await;
3061
3062 // force=true -> removes execution even on error
3063 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3064 assert!(
3065 service_executions_ref
3066 .read()
3067 .await
3068 .values()
3069 .all(IndexMap::is_empty)
3070 );
3071 }
3072
3073 #[tokio::test]
3074 async fn test_delete_execution_force_removes_on_negative_response() {
3075 let ecu_name = "TestECU".to_string();
3076 let mut mock_uds = MockUdsEcu::new();
3077 let mock_file_manager = MockFileManager::new();
3078
3079 mock_uds
3080 .expect_send()
3081 .times(1)
3082 .returning(|_, _, _, _, _| Ok(make_negative_response()));
3083
3084 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3085 ecu_name,
3086 mock_uds,
3087 mock_file_manager,
3088 );
3089 insert_test_ecu_lock(&state.locks, "TestECU").await;
3090
3091 let exec_id = uuid::Uuid::new_v4();
3092 state
3093 .service_executions
3094 .write()
3095 .await
3096 .entry("CalibrateSensor".to_string())
3097 .or_default()
3098 .insert(
3099 exec_id,
3100 ServiceExecution {
3101 parameters: serde_json::Map::new(),
3102 status: ExecutionStatus::Running,
3103 in_flight: false,
3104 is_created: true,
3105 },
3106 );
3107
3108 let service_executions_ref = Arc::clone(&state.service_executions);
3109
3110 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3111 UseApi(
3112 Secured(Box::new(TestSecurityPlugin)),
3113 std::marker::PhantomData,
3114 ),
3115 Path(id_handlers::ServiceAndIdPathParam {
3116 service: "CalibrateSensor".to_string(),
3117 id: exec_id.to_string(),
3118 }),
3119 WithRejection(
3120 Query(
3121 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3122 include_schema: false,
3123 suppress_service: false,
3124 force: true,
3125 },
3126 ),
3127 std::marker::PhantomData,
3128 ),
3129 State(state),
3130 )
3131 .await;
3132
3133 // force=true -> removes execution even on negative ECU response
3134 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3135 assert!(
3136 service_executions_ref
3137 .read()
3138 .await
3139 .values()
3140 .all(IndexMap::is_empty)
3141 );
3142 }
3143
3144 #[tokio::test]
3145 async fn test_delete_execution_without_force_returns_error_on_uds_failure() {
3146 let ecu_name = "TestECU".to_string();
3147 let mut mock_uds = MockUdsEcu::new();
3148 let mock_file_manager = MockFileManager::new();
3149
3150 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3151 Err(DiagServiceError::SendFailed("timeout".to_string()))
3152 });
3153
3154 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3155 ecu_name,
3156 mock_uds,
3157 mock_file_manager,
3158 );
3159 insert_test_ecu_lock(&state.locks, "TestECU").await;
3160
3161 let exec_id = uuid::Uuid::new_v4();
3162 state
3163 .service_executions
3164 .write()
3165 .await
3166 .entry("CalibrateSensor".to_string())
3167 .or_default()
3168 .insert(
3169 exec_id,
3170 ServiceExecution {
3171 parameters: serde_json::Map::new(),
3172 status: ExecutionStatus::Running,
3173 in_flight: false,
3174 is_created: true,
3175 },
3176 );
3177
3178 // Keep a reference to service_executions so we can verify after consuming state
3179 let service_executions_ref = Arc::clone(&state.service_executions);
3180
3181 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3182 UseApi(
3183 Secured(Box::new(TestSecurityPlugin)),
3184 std::marker::PhantomData,
3185 ),
3186 Path(id_handlers::ServiceAndIdPathParam {
3187 service: "CalibrateSensor".to_string(),
3188 id: exec_id.to_string(),
3189 }),
3190 WithRejection(
3191 Query(
3192 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3193 include_schema: false,
3194 suppress_service: false,
3195 force: false,
3196 },
3197 ),
3198 std::marker::PhantomData,
3199 ),
3200 State(state),
3201 )
3202 .await;
3203
3204 // force=false -> error should be returned, execution should remain
3205 assert!(response.status().is_client_error() || response.status().is_server_error());
3206 assert_eq!(service_executions_ref.read().await.len(), 1);
3207 }
3208
3209 #[tokio::test]
3210 async fn test_delete_execution_negative_response_resets_in_flight() {
3211 let ecu_name = "TestECU".to_string();
3212 let mut mock_uds = MockUdsEcu::new();
3213 let mock_file_manager = MockFileManager::new();
3214
3215 mock_uds
3216 .expect_send()
3217 .times(1)
3218 .returning(|_, _, _, _, _| Ok(make_negative_response()));
3219
3220 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3221 ecu_name,
3222 mock_uds,
3223 mock_file_manager,
3224 );
3225 insert_test_ecu_lock(&state.locks, "TestECU").await;
3226
3227 let exec_id = uuid::Uuid::new_v4();
3228 state
3229 .service_executions
3230 .write()
3231 .await
3232 .entry("CalibrateSensor".to_string())
3233 .or_default()
3234 .insert(
3235 exec_id,
3236 ServiceExecution {
3237 parameters: serde_json::Map::new(),
3238 status: ExecutionStatus::Running,
3239 in_flight: false,
3240 is_created: true,
3241 },
3242 );
3243
3244 let service_executions_ref = Arc::clone(&state.service_executions);
3245
3246 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3247 UseApi(
3248 Secured(Box::new(TestSecurityPlugin)),
3249 std::marker::PhantomData,
3250 ),
3251 Path(id_handlers::ServiceAndIdPathParam {
3252 service: "CalibrateSensor".to_string(),
3253 id: exec_id.to_string(),
3254 }),
3255 WithRejection(
3256 Query(
3257 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3258 include_schema: false,
3259 suppress_service: false,
3260 force: false,
3261 },
3262 ),
3263 std::marker::PhantomData,
3264 ),
3265 State(state),
3266 )
3267 .await;
3268
3269 // Negative ECU response without force -> error returned, in_flight reset
3270 assert!(response.status().is_client_error() || response.status().is_server_error());
3271 let guard = service_executions_ref.read().await;
3272 let exec = guard
3273 .get("CalibrateSensor")
3274 .and_then(|m| m.get(&exec_id))
3275 .expect("execution should still exist");
3276 assert!(!exec.in_flight, "in_flight should be reset to false");
3277 }
3278
3279 #[tokio::test]
3280 async fn test_delete_execution_suppress_service_removes_on_not_found() {
3281 let ecu_name = "TestECU".to_string();
3282 let mut mock_uds = MockUdsEcu::new();
3283 let mock_file_manager = MockFileManager::new();
3284
3285 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3286 Err(DiagServiceError::NotFound(
3287 "CalibrateSensor_Stop not found".to_string(),
3288 ))
3289 });
3290
3291 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3292 ecu_name,
3293 mock_uds,
3294 mock_file_manager,
3295 );
3296 insert_test_ecu_lock(&state.locks, "TestECU").await;
3297
3298 let exec_id = uuid::Uuid::new_v4();
3299 state
3300 .service_executions
3301 .write()
3302 .await
3303 .entry("CalibrateSensor".to_string())
3304 .or_default()
3305 .insert(
3306 exec_id,
3307 ServiceExecution {
3308 parameters: serde_json::Map::new(),
3309 status: ExecutionStatus::Running,
3310 in_flight: false,
3311 is_created: true,
3312 },
3313 );
3314
3315 // Keep a reference to service_executions so we can verify after consuming state
3316 let service_executions_ref = Arc::clone(&state.service_executions);
3317
3318 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3319 UseApi(
3320 Secured(Box::new(TestSecurityPlugin)),
3321 std::marker::PhantomData,
3322 ),
3323 Path(id_handlers::ServiceAndIdPathParam {
3324 service: "CalibrateSensor".to_string(),
3325 id: exec_id.to_string(),
3326 }),
3327 WithRejection(
3328 Query(
3329 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3330 include_schema: false,
3331 suppress_service: true,
3332 force: false,
3333 },
3334 ),
3335 std::marker::PhantomData,
3336 ),
3337 State(state),
3338 )
3339 .await;
3340
3341 // suppress_service=true on NotFound -> removes execution, returns 204
3342 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3343 assert!(
3344 service_executions_ref
3345 .read()
3346 .await
3347 .values()
3348 .all(IndexMap::is_empty)
3349 );
3350 }
3351
3352 #[tokio::test]
3353 async fn test_get_execution_in_flight_returns_conflict() {
3354 let ecu_name = "TestECU".to_string();
3355 let mock_uds = MockUdsEcu::new();
3356 let mock_file_manager = MockFileManager::new();
3357 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3358 ecu_name,
3359 mock_uds,
3360 mock_file_manager,
3361 );
3362
3363 let exec_id = uuid::Uuid::new_v4();
3364 state
3365 .service_executions
3366 .write()
3367 .await
3368 .entry("CalibrateSensor".to_string())
3369 .or_default()
3370 .insert(
3371 exec_id,
3372 ServiceExecution {
3373 parameters: serde_json::Map::new(),
3374 status: ExecutionStatus::Running,
3375 in_flight: true,
3376 is_created: true,
3377 },
3378 );
3379
3380 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
3381 UseApi(
3382 Secured(Box::new(TestSecurityPlugin)),
3383 std::marker::PhantomData,
3384 ),
3385 Path(id_handlers::ServiceAndIdPathParam {
3386 service: "CalibrateSensor".to_string(),
3387 id: exec_id.to_string(),
3388 }),
3389 WithRejection(
3390 Query(
3391 sovd_interfaces::components::ecu::operations::OperationQuery {
3392 include_schema: false,
3393 suppress_service: false,
3394 },
3395 ),
3396 std::marker::PhantomData,
3397 ),
3398 State(state),
3399 )
3400 .await;
3401
3402 assert_eq!(response.status(), StatusCode::CONFLICT);
3403 }
3404
3405 #[tokio::test]
3406 async fn test_delete_execution_in_flight_returns_conflict() {
3407 let ecu_name = "TestECU".to_string();
3408 let mock_uds = MockUdsEcu::new();
3409 let mock_file_manager = MockFileManager::new();
3410 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3411 ecu_name.clone(),
3412 mock_uds,
3413 mock_file_manager,
3414 );
3415 insert_test_ecu_lock(&state.locks, &ecu_name).await;
3416
3417 let exec_id = uuid::Uuid::new_v4();
3418 state
3419 .service_executions
3420 .write()
3421 .await
3422 .entry("CalibrateSensor".to_string())
3423 .or_default()
3424 .insert(
3425 exec_id,
3426 ServiceExecution {
3427 parameters: serde_json::Map::new(),
3428 status: ExecutionStatus::Running,
3429 in_flight: true,
3430 is_created: true,
3431 },
3432 );
3433
3434 let response = id_handlers::delete::<MockUdsEcu, MockFileManager>(
3435 UseApi(
3436 Secured(Box::new(TestSecurityPlugin)),
3437 std::marker::PhantomData,
3438 ),
3439 Path(id_handlers::ServiceAndIdPathParam {
3440 service: "CalibrateSensor".to_string(),
3441 id: exec_id.to_string(),
3442 }),
3443 WithRejection(
3444 Query(
3445 sovd_interfaces::components::ecu::operations::OperationDeleteQuery {
3446 include_schema: false,
3447 suppress_service: false,
3448 force: false,
3449 },
3450 ),
3451 std::marker::PhantomData,
3452 ),
3453 State(state),
3454 )
3455 .await;
3456
3457 assert_eq!(response.status(), StatusCode::CONFLICT);
3458 }
3459
3460 fn make_post_headers() -> axum::http::HeaderMap {
3461 let mut headers = axum::http::HeaderMap::new();
3462 headers.insert(
3463 axum::http::header::CONTENT_TYPE,
3464 axum::http::HeaderValue::from_static("application/json"),
3465 );
3466 headers.insert(
3467 axum::http::header::ACCEPT,
3468 axum::http::HeaderValue::from_static("application/json"),
3469 );
3470 headers
3471 }
3472
3473 #[tokio::test]
3474 async fn test_post_operation_conflict_when_running_execution_exists() {
3475 let ecu_name = "TestECU".to_string();
3476 let mock_uds = MockUdsEcu::new();
3477 let mock_file_manager = MockFileManager::new();
3478
3479 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3480 ecu_name.clone(),
3481 mock_uds,
3482 mock_file_manager,
3483 );
3484
3485 // Pre-populate a running execution for CalibrateSensor
3486 state
3487 .service_executions
3488 .write()
3489 .await
3490 .entry("CalibrateSensor".to_string())
3491 .or_default()
3492 .insert(
3493 uuid::Uuid::new_v4(),
3494 ServiceExecution {
3495 parameters: serde_json::Map::new(),
3496 status: ExecutionStatus::Running,
3497 in_flight: false,
3498 is_created: true,
3499 },
3500 );
3501
3502 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3503 handlers::WriteHandlerRequest {
3504 service: "CalibrateSensor".to_string(),
3505 headers: make_post_headers(),
3506 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3507 },
3508 &ecu_name,
3509 &state.uds,
3510 Arc::clone(&state.service_executions),
3511 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3512 handlers::WriteHandlerOptions {
3513 include_schema: false,
3514 suppress_service: false,
3515 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3516 },
3517 &std::sync::atomic::AtomicBool::new(false),
3518 )
3519 .await;
3520
3521 assert_eq!(response.status(), StatusCode::CONFLICT);
3522 }
3523
3524 #[tokio::test]
3525 async fn test_post_operation_no_conflict_for_different_service() {
3526 // An execution running for ServiceA must NOT block ServiceB
3527 let ecu_name = "TestECU".to_string();
3528 let mut mock_uds = MockUdsEcu::new();
3529 let mock_file_manager = MockFileManager::new();
3530
3531 mock_uds
3532 .expect_get_routine_subfunctions()
3533 .times(1)
3534 .returning(|_, _, _| {
3535 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3536 has_stop: false,
3537 has_request_results: false,
3538 })
3539 });
3540 mock_uds
3541 .expect_send()
3542 .times(1)
3543 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3544
3545 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3546 ecu_name.clone(),
3547 mock_uds,
3548 mock_file_manager,
3549 );
3550
3551 // Pre-populate a running execution for a DIFFERENT service
3552 state
3553 .service_executions
3554 .write()
3555 .await
3556 .entry("OtherService".to_string())
3557 .or_default()
3558 .insert(
3559 uuid::Uuid::new_v4(),
3560 ServiceExecution {
3561 parameters: serde_json::Map::new(),
3562 status: ExecutionStatus::Running,
3563 in_flight: false,
3564 is_created: true,
3565 },
3566 );
3567
3568 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3569 handlers::WriteHandlerRequest {
3570 service: "CalibrateSensor".to_string(),
3571 headers: make_post_headers(),
3572 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3573 },
3574 &ecu_name,
3575 &state.uds,
3576 Arc::clone(&state.service_executions),
3577 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3578 handlers::WriteHandlerOptions {
3579 include_schema: false,
3580 suppress_service: false,
3581 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3582 },
3583 &std::sync::atomic::AtomicBool::new(false),
3584 )
3585 .await;
3586
3587 // Different service -> no conflict, should pass through to 200
3588 assert_eq!(response.status(), StatusCode::OK);
3589 }
3590
3591 #[tokio::test]
3592 async fn test_post_operation_service_not_found_returns_404() {
3593 let ecu_name = "TestECU".to_string();
3594 let mut mock_uds = MockUdsEcu::new();
3595 let mock_file_manager = MockFileManager::new();
3596
3597 mock_uds
3598 .expect_get_routine_subfunctions()
3599 .withf(|ecu, svc, _p| ecu == "TestECU" && svc == "CalibrateSensor")
3600 .times(1)
3601 .returning(|_, _, _| {
3602 Err(DiagServiceError::NotFound(
3603 "Routine 'CalibrateSensor' not found in ECU description".to_string(),
3604 ))
3605 });
3606
3607 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3608 ecu_name.clone(),
3609 mock_uds,
3610 mock_file_manager,
3611 );
3612
3613 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3614 handlers::WriteHandlerRequest {
3615 service: "CalibrateSensor".to_string(),
3616 headers: make_post_headers(),
3617 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3618 },
3619 &ecu_name,
3620 &state.uds,
3621 Arc::clone(&state.service_executions),
3622 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3623 handlers::WriteHandlerOptions {
3624 include_schema: false,
3625 suppress_service: false,
3626 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3627 },
3628 &std::sync::atomic::AtomicBool::new(false),
3629 )
3630 .await;
3631
3632 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3633 }
3634
3635 #[tokio::test]
3636 async fn test_post_operation_sync_returns_200_on_empty_response() {
3637 let ecu_name = "TestECU".to_string();
3638 let mut mock_uds = MockUdsEcu::new();
3639 let mock_file_manager = MockFileManager::new();
3640
3641 mock_uds
3642 .expect_get_routine_subfunctions()
3643 .times(1)
3644 .returning(|_, _, _| {
3645 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3646 has_stop: false,
3647 has_request_results: false,
3648 })
3649 });
3650 mock_uds
3651 .expect_send()
3652 .times(1)
3653 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3654
3655 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3656 ecu_name.clone(),
3657 mock_uds,
3658 mock_file_manager,
3659 );
3660
3661 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3662 handlers::WriteHandlerRequest {
3663 service: "CalibrateSensor".to_string(),
3664 headers: make_post_headers(),
3665 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3666 },
3667 &ecu_name,
3668 &state.uds,
3669 Arc::clone(&state.service_executions),
3670 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3671 handlers::WriteHandlerOptions {
3672 include_schema: false,
3673 suppress_service: false,
3674 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3675 },
3676 &std::sync::atomic::AtomicBool::new(false),
3677 )
3678 .await;
3679
3680 assert_eq!(response.status(), StatusCode::OK);
3681 }
3682
3683 #[tokio::test]
3684 async fn test_post_operation_async_returns_202_and_tracks_execution() {
3685 let ecu_name = "TestECU".to_string();
3686 let mut mock_uds = MockUdsEcu::new();
3687 let mock_file_manager = MockFileManager::new();
3688
3689 mock_uds
3690 .expect_get_routine_subfunctions()
3691 .times(1)
3692 .returning(|_, _, _| {
3693 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3694 has_stop: true,
3695 has_request_results: true,
3696 })
3697 });
3698 mock_uds
3699 .expect_send()
3700 .times(1)
3701 .returning(|_, _, _, _, _| Ok(make_empty_positive_response()));
3702
3703 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3704 ecu_name.clone(),
3705 mock_uds,
3706 mock_file_manager,
3707 );
3708
3709 let service_executions_ref = Arc::clone(&state.service_executions);
3710
3711 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3712 handlers::WriteHandlerRequest {
3713 service: "CalibrateSensor".to_string(),
3714 headers: make_post_headers(),
3715 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3716 },
3717 &ecu_name,
3718 &state.uds,
3719 Arc::clone(&state.service_executions),
3720 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3721 handlers::WriteHandlerOptions {
3722 include_schema: false,
3723 suppress_service: false,
3724 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3725 },
3726 &std::sync::atomic::AtomicBool::new(false),
3727 )
3728 .await;
3729
3730 assert_eq!(response.status(), StatusCode::ACCEPTED);
3731 assert_eq!(service_executions_ref.read().await.len(), 1);
3732 }
3733
3734 #[tokio::test]
3735 async fn test_post_operation_suppress_service_async_skips_send_returns_202_and_tracks() {
3736 let ecu_name = "TestECU".to_string();
3737 let mut mock_uds = MockUdsEcu::new();
3738 let mock_file_manager = MockFileManager::new();
3739
3740 mock_uds.expect_get_routine_subfunctions().times(0);
3741 // send must NOT be called when suppress_service=true
3742 mock_uds.expect_send().times(0);
3743
3744 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3745 ecu_name.clone(),
3746 mock_uds,
3747 mock_file_manager,
3748 );
3749
3750 let service_executions_ref = Arc::clone(&state.service_executions);
3751
3752 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3753 handlers::WriteHandlerRequest {
3754 service: "CalibrateSensor".to_string(),
3755 headers: make_post_headers(),
3756 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3757 },
3758 &ecu_name,
3759 &state.uds,
3760 Arc::clone(&state.service_executions),
3761 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3762 handlers::WriteHandlerOptions {
3763 include_schema: false,
3764 suppress_service: true,
3765 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3766 },
3767 &std::sync::atomic::AtomicBool::new(false),
3768 )
3769 .await;
3770
3771 assert_eq!(response.status(), StatusCode::ACCEPTED);
3772 // execution still tracked even though UDS was not called
3773 assert_eq!(service_executions_ref.read().await.len(), 1);
3774 }
3775
3776 #[tokio::test]
3777 async fn test_post_operation_async_into_json_error_surfaces_in_errors_not_500() {
3778 let ecu_name = "TestECU".to_string();
3779 let mut mock_uds = MockUdsEcu::new();
3780 let mock_file_manager = MockFileManager::new();
3781
3782 mock_uds
3783 .expect_get_routine_subfunctions()
3784 .times(1)
3785 .returning(|_, _, _| {
3786 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3787 has_stop: true,
3788 has_request_results: true,
3789 })
3790 });
3791 // send returns a response whose into_json() fails
3792 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3793 let mut resp = MockDiagServiceResponse::new();
3794 resp.expect_response_type()
3795 .returning(|| DiagServiceResponseType::Positive);
3796 resp.expect_is_empty().returning(|| false);
3797 resp.expect_into_json().return_once(|| {
3798 Err(DiagServiceError::BadPayload(
3799 "simulated parse failure".to_string(),
3800 ))
3801 });
3802 Ok(resp)
3803 });
3804
3805 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3806 ecu_name.clone(),
3807 mock_uds,
3808 mock_file_manager,
3809 );
3810
3811 let service_executions_ref = Arc::clone(&state.service_executions);
3812
3813 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3814 handlers::WriteHandlerRequest {
3815 service: "CalibrateSensor".to_string(),
3816 headers: make_post_headers(),
3817 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3818 },
3819 &ecu_name,
3820 &state.uds,
3821 Arc::clone(&state.service_executions),
3822 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3823 handlers::WriteHandlerOptions {
3824 include_schema: false,
3825 suppress_service: false,
3826 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3827 },
3828 &std::sync::atomic::AtomicBool::new(false),
3829 )
3830 .await;
3831
3832 // Must be 202, not 500 - spec Table 184 body has only id + status
3833 assert_eq!(response.status(), StatusCode::ACCEPTED);
3834 // Execution must still be tracked
3835 assert_eq!(service_executions_ref.read().await.len(), 1);
3836 // Body must contain id and status, no errors field
3837 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3838 .await
3839 .unwrap();
3840 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3841 assert!(result.get("id").is_some(), "202 body must have id");
3842 assert!(result.get("status").is_some(), "202 body must have status");
3843 assert!(
3844 result.get("errors").is_none(),
3845 "202 body must not contain errors per spec Table 184"
3846 );
3847 }
3848
3849 #[tokio::test]
3850 async fn test_post_operation_async_non_object_json_surfaces_in_errors_not_500() {
3851 let ecu_name = "TestECU".to_string();
3852 let mut mock_uds = MockUdsEcu::new();
3853 let mock_file_manager = MockFileManager::new();
3854
3855 mock_uds
3856 .expect_get_routine_subfunctions()
3857 .times(1)
3858 .returning(|_, _, _| {
3859 Ok(cda_interfaces::datatypes::RoutineSubfunctions {
3860 has_stop: true,
3861 has_request_results: true,
3862 })
3863 });
3864 // send returns a response whose into_json() gives a non-Object JSON value
3865 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3866 let mut resp = MockDiagServiceResponse::new();
3867 resp.expect_response_type()
3868 .returning(|| DiagServiceResponseType::Positive);
3869 resp.expect_is_empty().returning(|| false);
3870 resp.expect_into_json().return_once(|| {
3871 Ok(DiagServiceJsonResponse {
3872 data: serde_json::Value::String("unexpected_string".to_string()),
3873 errors: vec![],
3874 })
3875 });
3876 Ok(resp)
3877 });
3878
3879 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3880 ecu_name.clone(),
3881 mock_uds,
3882 mock_file_manager,
3883 );
3884
3885 let service_executions_ref = Arc::clone(&state.service_executions);
3886
3887 let response = handlers::ecu_operation_write_handler::<MockUdsEcu>(
3888 handlers::WriteHandlerRequest {
3889 service: "CalibrateSensor".to_string(),
3890 headers: make_post_headers(),
3891 body: axum::body::Bytes::from_static(b"{\"parameters\":{}}"),
3892 },
3893 &ecu_name,
3894 &state.uds,
3895 Arc::clone(&state.service_executions),
3896 Box::new(cda_plugin_security::mock::TestSecurityPlugin),
3897 handlers::WriteHandlerOptions {
3898 include_schema: false,
3899 suppress_service: false,
3900 base_path: "http://localhost/operations/CalibrateSensor/executions".to_string(),
3901 },
3902 &std::sync::atomic::AtomicBool::new(false),
3903 )
3904 .await;
3905
3906 // Must be 202, not 500 - spec Table 184 body has only id + status
3907 assert_eq!(response.status(), StatusCode::ACCEPTED);
3908 assert_eq!(service_executions_ref.read().await.len(), 1);
3909 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3910 .await
3911 .unwrap();
3912 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3913 assert!(result.get("id").is_some(), "202 body must have id");
3914 assert!(result.get("status").is_some(), "202 body must have status");
3915 assert!(
3916 result.get("errors").is_none(),
3917 "202 body must not contain errors per spec Table 184"
3918 );
3919 }
3920
3921 #[tokio::test]
3922 async fn test_request_results_into_json_error_surfaces_in_errors_field() {
3923 let ecu_name = "TestECU".to_string();
3924 let mut mock_uds = MockUdsEcu::new();
3925 let mock_file_manager = MockFileManager::new();
3926
3927 // RequestResults returns a non-empty response whose into_json() fails
3928 mock_uds.expect_send().times(1).returning(|_, _, _, _, _| {
3929 let mut resp = MockDiagServiceResponse::new();
3930 resp.expect_response_type()
3931 .returning(|| DiagServiceResponseType::Positive);
3932 resp.expect_is_empty().returning(|| false);
3933 resp.expect_into_json().return_once(|| {
3934 Err(DiagServiceError::BadPayload(
3935 "simulated parse failure".to_string(),
3936 ))
3937 });
3938 Ok(resp)
3939 });
3940
3941 let state = create_test_webserver_state::<MockUdsEcu, MockFileManager>(
3942 ecu_name,
3943 mock_uds,
3944 mock_file_manager,
3945 );
3946
3947 let exec_id = uuid::Uuid::new_v4();
3948 state
3949 .service_executions
3950 .write()
3951 .await
3952 .entry("CalibrateSensor".to_string())
3953 .or_default()
3954 .insert(
3955 exec_id,
3956 ServiceExecution {
3957 parameters: serde_json::Map::new(),
3958 status: ExecutionStatus::Running,
3959 in_flight: false,
3960 is_created: true,
3961 },
3962 );
3963
3964 let response = id_handlers::get::<MockUdsEcu, MockFileManager>(
3965 UseApi(
3966 Secured(Box::new(TestSecurityPlugin)),
3967 std::marker::PhantomData,
3968 ),
3969 Path(id_handlers::ServiceAndIdPathParam {
3970 service: "CalibrateSensor".to_string(),
3971 id: exec_id.to_string(),
3972 }),
3973 WithRejection(
3974 Query(
3975 sovd_interfaces::components::ecu::operations::OperationQuery {
3976 include_schema: false,
3977 suppress_service: false,
3978 },
3979 ),
3980 std::marker::PhantomData,
3981 ),
3982 State(state),
3983 )
3984 .await;
3985
3986 // Must be 200, not 500
3987 assert_eq!(response.status(), StatusCode::OK);
3988 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
3989 .await
3990 .unwrap();
3991 let result: serde_json::Value = serde_json::from_slice(&body).unwrap();
3992 // Spec Table 189: field is named `error` (singular key, array value)
3993 let errors = result.get("error").expect("missing error field");
3994 assert!(
3995 errors.as_array().is_some_and(|a| !a.is_empty()),
3996 "error should be non-empty when RequestResults into_json fails"
3997 );
3998 }
3999 }
4000}