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