1use std::convert::Infallible;
14use std::future::Future;
15use std::time::Duration;
16
17use axum::extract::{Path, Query, State};
18use axum::response::sse::{Event, KeepAlive, Sse};
19use axum::routing::{delete, get, post};
20use axum::{Json, Router};
21use bhtune_cli::commands::tune::sample_quality_from_driver;
22use bhtune_db::models::SampleQuality;
23use bhtune_driver::{
24 BrowseNode, BrowseNodeKind, BrowsePage, BrowsePageRequest, BrowseSource, Driver,
25 DriverCapabilities, DriverResult, IndexedSearchMatch, IndexedSearchProgress,
26 NamespaceOrganization, OpcDaDriver, SearchEvent, SearchIndexControlAction, SearchIndexRequest,
27 SearchIndexResponse, SearchIndexStatus, SearchMatch, SearchMatchMode, SearchRequest,
28 list_opcda_servers,
29};
30use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use utoipa::{IntoParams, ToSchema};
33
34use crate::error::{ApiError, ErrorBody};
35use crate::state::AppState;
36
37const OPC_QUERY_TIMEOUT_SECS: u64 = 30;
46
47async fn with_timeout<T>(
54 what: &str,
55 fut: impl Future<Output = DriverResult<T>>,
56) -> Result<T, ApiError> {
57 match tokio::time::timeout(Duration::from_secs(OPC_QUERY_TIMEOUT_SECS), fut).await {
58 Ok(Ok(value)) => Ok(value),
59 Ok(Err(err)) => Err(ApiError::BadRequest(format!("{what}: {err}"))),
60 Err(_) => Err(ApiError::BadRequest(format!(
61 "{what}: no response within {OPC_QUERY_TIMEOUT_SECS}s"
62 ))),
63 }
64}
65
66#[derive(Debug, Deserialize, IntoParams)]
68#[into_params(parameter_in = Query)]
69pub struct OpcServersQuery {
70 pub bridge_host: Option<String>,
73}
74
75#[derive(Debug, Serialize, ToSchema)]
77pub struct OpcServersResponse {
78 pub servers: Vec<String>,
79}
80
81#[utoipa::path(
89 get,
90 path = "/api/opc/servers",
91 tag = "opc",
92 params(OpcServersQuery),
93 responses(
94 (status = 200, body = OpcServersResponse),
95 (status = 400, description = "The bridge gateway could not be reached in time.", body = ErrorBody),
96 ),
97)]
98pub(crate) async fn servers(
99 State(state): State<AppState>,
100 Query(query): Query<OpcServersQuery>,
101) -> Result<Json<OpcServersResponse>, ApiError> {
102 let config = state.config_snapshot()?;
103 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
104 let servers = with_timeout("list OPC DA servers", list_opcda_servers(&bridge_host)).await?;
105 Ok(Json(OpcServersResponse { servers }))
106}
107
108#[derive(Debug, Deserialize, IntoParams)]
110#[into_params(parameter_in = Query)]
111pub struct OpcServerQuery {
112 pub bridge_host: Option<String>,
113 pub opc_server: Option<String>,
114}
115
116#[derive(Debug, Serialize, ToSchema)]
117pub struct OpcCapabilitiesResponse {
118 pub application_version: String,
119 pub protocol_version: String,
120 pub max_page_size: u32,
121 pub supports_browse_sessions: bool,
122 pub supports_search: bool,
123 pub organization: String,
124 pub source: String,
125 pub supports_indexed_search: bool,
126 pub indexed_search_protocol_version: String,
127 pub max_indexed_search_results: u32,
128 pub search_index_state: String,
129}
130
131impl From<DriverCapabilities> for OpcCapabilitiesResponse {
132 fn from(capabilities: DriverCapabilities) -> Self {
133 Self {
134 application_version: capabilities.application_version,
135 protocol_version: capabilities.protocol_version,
136 max_page_size: capabilities.max_page_size,
137 supports_browse_sessions: capabilities.supports_browse_sessions,
138 supports_search: capabilities.supports_search,
139 organization: organization_name(capabilities.organization).to_string(),
140 source: source_name(capabilities.source).to_string(),
141 supports_indexed_search: capabilities.supports_indexed_search,
142 indexed_search_protocol_version: capabilities.indexed_search_protocol_version,
143 max_indexed_search_results: capabilities.max_indexed_search_results,
144 search_index_state: capabilities.search_index_state.to_string(),
145 }
146 }
147}
148
149#[utoipa::path(
151 get,
152 path = "/api/opc/capabilities",
153 tag = "opc",
154 params(OpcServerQuery),
155 responses(
156 (status = 200, body = OpcCapabilitiesResponse),
157 (status = 400, description = "The bridge or OPC server could not be reached.", body = ErrorBody),
158 ),
159)]
160pub(crate) async fn capabilities(
161 State(state): State<AppState>,
162 Query(query): Query<OpcServerQuery>,
163) -> Result<Json<OpcCapabilitiesResponse>, ApiError> {
164 let config = state.config_snapshot()?;
165 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
166 let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
167 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
168 let driver = with_timeout(
169 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
170 OpcDaDriver::connect(&bridge_host, opc_server),
171 )
172 .await?;
173 let capabilities =
174 with_timeout("discover OPC browse capabilities", driver.capabilities()).await?;
175 Ok(Json(capabilities.into()))
176}
177
178#[derive(Debug, Deserialize, IntoParams)]
180#[into_params(parameter_in = Query)]
181pub struct OpcSearchIndexServerQuery {
182 pub bridge_host: Option<String>,
183 pub opc_server: Option<String>,
184}
185
186#[derive(Debug, Serialize, ToSchema)]
187pub struct OpcIndexedSearchProgressResponse {
188 pub branches_visited: u64,
189 pub entries_seen: u64,
190 pub unique_items: u64,
191 pub active_time_ms: u64,
192 pub paused_time_ms: u64,
193 pub items_per_second: f64,
194 pub estimated_remaining_ms: Option<u64>,
195}
196
197impl From<IndexedSearchProgress> for OpcIndexedSearchProgressResponse {
198 fn from(progress: IndexedSearchProgress) -> Self {
199 Self {
200 branches_visited: progress.branches_visited,
201 entries_seen: progress.entries_seen,
202 unique_items: progress.unique_items,
203 active_time_ms: progress.active_time_ms,
204 paused_time_ms: progress.paused_time_ms,
205 items_per_second: progress.items_per_second,
206 estimated_remaining_ms: progress.estimated_remaining_ms,
207 }
208 }
209}
210
211#[derive(Debug, Serialize, ToSchema)]
212pub struct OpcSearchIndexStatusResponse {
213 pub server: String,
214 pub state: String,
215 pub auto_refresh_enabled: bool,
216 pub active_generation: u64,
217 pub entry_count: u64,
218 pub unique_item_count: u64,
219 pub started_at: Option<String>,
220 pub completed_at: Option<String>,
221 pub last_error: Option<String>,
222 pub database_bytes: u64,
223 pub organization: String,
224 pub source: String,
225 pub progress: Option<OpcIndexedSearchProgressResponse>,
226 pub scheduler: OpcIndexSchedulerResponse,
227}
228
229#[derive(Debug, Serialize, ToSchema)]
230pub struct OpcIndexSchedulerResponse {
231 pub next_refresh_at: Option<String>,
232 pub last_attempt_at: Option<String>,
233 pub last_success_at: Option<String>,
234 pub last_success_duration_ms: Option<u64>,
235 pub retry_after: Option<String>,
236 pub consecutive_failures: u32,
237 pub circuit_open: bool,
238}
239
240impl From<SearchIndexStatus> for OpcSearchIndexStatusResponse {
241 fn from(status: SearchIndexStatus) -> Self {
242 Self {
243 server: status.server,
244 state: status.state.to_string(),
245 auto_refresh_enabled: status.auto_refresh_enabled,
246 active_generation: status.active_generation,
247 entry_count: status.entry_count,
248 unique_item_count: status.unique_item_count,
249 started_at: status.started_at,
250 completed_at: status.completed_at,
251 last_error: status.last_error,
252 database_bytes: status.database_bytes,
253 organization: organization_name(status.organization).to_string(),
254 source: source_name(status.source).to_string(),
255 progress: status.progress.map(Into::into),
256 scheduler: OpcIndexSchedulerResponse {
257 next_refresh_at: status.scheduler.next_refresh_at,
258 last_attempt_at: status.scheduler.last_attempt_at,
259 last_success_at: status.scheduler.last_success_at,
260 last_success_duration_ms: status.scheduler.last_success_duration_ms,
261 retry_after: status.scheduler.retry_after,
262 consecutive_failures: status.scheduler.consecutive_failures,
263 circuit_open: status.scheduler.circuit_open,
264 },
265 }
266 }
267}
268
269#[derive(Debug, Serialize, ToSchema)]
270pub struct OpcIndexedSearchMatchResponse {
271 pub item_id: String,
272 pub display_name: String,
273 pub kind: OpcBrowseNodeKind,
274 pub breadcrumbs: Vec<String>,
275}
276
277impl From<IndexedSearchMatch> for OpcIndexedSearchMatchResponse {
278 fn from(found: IndexedSearchMatch) -> Self {
279 Self {
280 item_id: found.item_id,
281 display_name: found.display_name,
282 kind: found.kind.into(),
283 breadcrumbs: found.breadcrumbs,
284 }
285 }
286}
287
288#[derive(Debug, Serialize, ToSchema)]
289pub struct OpcSearchIndexResponse {
290 pub matches: Vec<OpcIndexedSearchMatchResponse>,
291 pub has_more: bool,
292 pub status: OpcSearchIndexStatusResponse,
293}
294
295impl From<SearchIndexResponse> for OpcSearchIndexResponse {
296 fn from(response: SearchIndexResponse) -> Self {
297 Self {
298 matches: response.matches.into_iter().map(Into::into).collect(),
299 has_more: response.has_more,
300 status: response.status.into(),
301 }
302 }
303}
304
305async fn connect_search_index_driver(
306 state: &AppState,
307 bridge_host: Option<String>,
308 opc_server: Option<String>,
309) -> Result<OpcDaDriver, ApiError> {
310 let config = state.config_snapshot()?;
311 let bridge_host = bhtune_cli::config::resolve_bridge_host(bridge_host, &config);
312 let opc_server = bhtune_cli::config::resolve_server(opc_server, &config)
313 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
314 with_timeout(
315 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
316 OpcDaDriver::connect(&bridge_host, opc_server),
317 )
318 .await
319}
320
321#[utoipa::path(
323 get,
324 path = "/api/opc/search-index/status",
325 tag = "opc",
326 params(OpcSearchIndexServerQuery),
327 responses(
328 (status = 200, body = OpcSearchIndexStatusResponse),
329 (status = 400, description = "The bridge or OPC server could not be reached.", body = ErrorBody),
330 ),
331)]
332pub(crate) async fn search_index_status(
333 State(state): State<AppState>,
334 Query(query): Query<OpcSearchIndexServerQuery>,
335) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
336 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
337 let status = with_timeout("read OPC search-index status", driver.search_index_status()).await?;
338 Ok(Json(status.into()))
339}
340
341#[derive(Debug, Deserialize, IntoParams)]
344#[into_params(parameter_in = Query)]
345pub struct OpcSearchIndexQuery {
346 pub bridge_host: Option<String>,
347 pub opc_server: Option<String>,
348 pub query: String,
349 #[serde(default = "default_search_match_mode")]
350 pub match_mode: String,
351 #[serde(default = "default_index_search_max_results")]
352 #[param(minimum = 1)]
353 pub max_results: u32,
354}
355
356fn default_index_search_max_results() -> u32 {
357 bhtune_driver::DEFAULT_INDEX_SEARCH_MAX_RESULTS
358}
359
360#[utoipa::path(
361 get,
362 path = "/api/opc/search-index/search",
363 tag = "opc",
364 params(OpcSearchIndexQuery),
365 responses(
366 (status = 200, body = OpcSearchIndexResponse),
367 (status = 400, description = "The indexed-search request or gateway connection is invalid.", body = ErrorBody),
368 ),
369)]
370pub(crate) async fn search_index(
371 State(state): State<AppState>,
372 Query(query): Query<OpcSearchIndexQuery>,
373) -> Result<Json<OpcSearchIndexResponse>, ApiError> {
374 if query.query.trim().is_empty() {
375 return Err(ApiError::BadRequest(
376 "a search query is required".to_string(),
377 ));
378 }
379 let match_mode = parse_search_match_mode(&query.match_mode)?;
380 let max_results = validate_positive(query.max_results, "max_results")?;
381 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
382 let response = with_timeout(
383 "search the OPC namespace index",
384 driver.search_index(SearchIndexRequest::new(
385 query.query,
386 match_mode,
387 max_results,
388 )),
389 )
390 .await?;
391 Ok(Json(response.into()))
392}
393
394#[derive(Debug, Deserialize, IntoParams)]
396#[into_params(parameter_in = Query)]
397pub struct OpcSearchIndexRefreshQuery {
398 pub bridge_host: Option<String>,
399 pub opc_server: Option<String>,
400 pub force: Option<bool>,
401}
402
403#[utoipa::path(
404 post,
405 path = "/api/opc/search-index/refresh",
406 tag = "opc",
407 params(OpcSearchIndexRefreshQuery),
408 responses(
409 (status = 200, body = OpcSearchIndexStatusResponse),
410 (status = 400, description = "The refresh request or gateway connection is invalid.", body = ErrorBody),
411 ),
412)]
413pub(crate) async fn refresh_search_index(
414 State(state): State<AppState>,
415 Query(query): Query<OpcSearchIndexRefreshQuery>,
416) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
417 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
418 let status = with_timeout(
419 "refresh the OPC namespace index",
420 driver.refresh_search_index(query.force.unwrap_or(false)),
421 )
422 .await?;
423 Ok(Json(status.into()))
424}
425
426#[derive(Debug, Deserialize, IntoParams)]
428#[into_params(parameter_in = Query)]
429pub struct OpcSearchIndexAutoRefreshQuery {
430 pub bridge_host: Option<String>,
431 pub opc_server: Option<String>,
432 pub enabled: bool,
433}
434
435#[utoipa::path(
436 post,
437 path = "/api/opc/search-index/auto-refresh",
438 tag = "opc",
439 params(OpcSearchIndexAutoRefreshQuery),
440 responses(
441 (status = 200, body = OpcSearchIndexStatusResponse),
442 (status = 400, description = "The auto-refresh request or gateway connection is invalid.", body = ErrorBody),
443 ),
444)]
445pub(crate) async fn set_search_index_auto_refresh(
446 State(state): State<AppState>,
447 Query(query): Query<OpcSearchIndexAutoRefreshQuery>,
448) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
449 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
450 let status = with_timeout(
451 "set OPC namespace index auto-refresh",
452 driver.set_search_index_auto_refresh(query.enabled),
453 )
454 .await?;
455 Ok(Json(status.into()))
456}
457
458#[utoipa::path(
459 delete,
460 path = "/api/opc/search-index",
461 tag = "opc",
462 params(OpcSearchIndexServerQuery),
463 responses(
464 (status = 200, body = OpcSearchIndexStatusResponse),
465 (status = 400, description = "The delete request or gateway connection is invalid.", body = ErrorBody),
466 ),
467)]
468pub(crate) async fn delete_search_index(
469 State(state): State<AppState>,
470 Query(query): Query<OpcSearchIndexServerQuery>,
471) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
472 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
473 let status = with_timeout(
474 "delete the OPC namespace index",
475 driver.delete_search_index(),
476 )
477 .await?;
478 Ok(Json(status.into()))
479}
480
481#[derive(Debug, Deserialize, IntoParams)]
483#[into_params(parameter_in = Query)]
484pub struct OpcSearchIndexControlQuery {
485 pub bridge_host: Option<String>,
486 pub opc_server: Option<String>,
487 pub action: String,
488}
489
490fn parse_search_index_control_action(value: &str) -> Result<SearchIndexControlAction, ApiError> {
491 match value {
492 "pause" => Ok(SearchIndexControlAction::Pause),
493 "resume" => Ok(SearchIndexControlAction::Resume),
494 "cancel" => Ok(SearchIndexControlAction::Cancel),
495 _ => Err(ApiError::BadRequest(
496 "action must be one of: pause, resume, cancel".to_string(),
497 )),
498 }
499}
500
501#[utoipa::path(
502 post,
503 path = "/api/opc/search-index/control",
504 tag = "opc",
505 params(OpcSearchIndexControlQuery),
506 responses(
507 (status = 200, body = OpcSearchIndexStatusResponse),
508 (status = 400, description = "The control action or gateway connection is invalid.", body = ErrorBody),
509 ),
510)]
511pub(crate) async fn control_search_index(
512 State(state): State<AppState>,
513 Query(query): Query<OpcSearchIndexControlQuery>,
514) -> Result<Json<OpcSearchIndexStatusResponse>, ApiError> {
515 let action = parse_search_index_control_action(&query.action)?;
516 let driver = connect_search_index_driver(&state, query.bridge_host, query.opc_server).await?;
517 let status = with_timeout(
518 "control the OPC namespace index",
519 driver.control_search_index(action),
520 )
521 .await?;
522 Ok(Json(status.into()))
523}
524
525#[derive(Debug, Deserialize, IntoParams)]
527#[into_params(parameter_in = Query)]
528pub struct OpcBrowseQuery {
529 pub bridge_host: Option<String>,
530 pub opc_server: Option<String>,
531 pub session_id: Option<String>,
532 pub parent_node_key: Option<String>,
533 pub page_token: Option<String>,
534 #[serde(default = "default_page_size")]
535 pub page_size: u32,
536 pub refresh: Option<bool>,
537}
538
539fn default_page_size() -> u32 {
540 bhtune_driver::DEFAULT_PAGE_SIZE
541}
542
543fn validate_positive(value: u32, field: &str) -> Result<u32, ApiError> {
544 if value == 0 {
545 return Err(ApiError::BadRequest(format!(
546 "{field} must be greater than zero"
547 )));
548 }
549 Ok(value)
550}
551
552#[derive(Debug, Clone, Copy, Serialize, ToSchema)]
553#[serde(rename_all = "snake_case")]
554pub enum OpcBrowseNodeKind {
555 Unspecified,
556 Branch,
557 Item,
558 BranchAndItem,
559}
560
561impl From<BrowseNodeKind> for OpcBrowseNodeKind {
562 fn from(kind: BrowseNodeKind) -> Self {
563 match kind {
564 BrowseNodeKind::Unspecified => Self::Unspecified,
565 BrowseNodeKind::Branch => Self::Branch,
566 BrowseNodeKind::Item => Self::Item,
567 BrowseNodeKind::BranchAndItem => Self::BranchAndItem,
568 }
569 }
570}
571
572#[derive(Debug, Serialize, ToSchema)]
576pub struct OpcBrowseNodeResponse {
577 pub node_key: String,
578 pub display_name: String,
579 pub kind: OpcBrowseNodeKind,
580 pub item_id: Option<String>,
581}
582
583impl From<BrowseNode> for OpcBrowseNodeResponse {
584 fn from(node: BrowseNode) -> Self {
585 Self {
586 node_key: node.node_key,
587 display_name: node.display_name,
588 kind: node.kind.into(),
589 item_id: node.item_id,
590 }
591 }
592}
593
594#[derive(Debug, Serialize, ToSchema)]
595pub struct OpcBrowseResponse {
596 pub session_id: String,
597 pub nodes: Vec<OpcBrowseNodeResponse>,
598 pub next_page_token: Option<String>,
599 pub complete: bool,
600 pub organization: String,
601 pub source: String,
602 pub warning: Option<String>,
603}
604
605impl From<BrowsePage> for OpcBrowseResponse {
606 fn from(page: BrowsePage) -> Self {
607 Self {
608 session_id: page.session_id,
609 nodes: page.nodes.into_iter().map(Into::into).collect(),
610 next_page_token: page.next_page_token,
611 complete: page.complete,
612 organization: organization_name(page.organization).to_string(),
613 source: source_name(page.source).to_string(),
614 warning: page.warning,
615 }
616 }
617}
618
619fn organization_name(value: NamespaceOrganization) -> &'static str {
620 match value {
621 NamespaceOrganization::Unspecified => "unspecified",
622 NamespaceOrganization::Flat => "flat",
623 NamespaceOrganization::Hierarchical => "hierarchical",
624 }
625}
626
627fn source_name(value: BrowseSource) -> &'static str {
628 match value {
629 BrowseSource::Unspecified => "unspecified",
630 BrowseSource::Da3 => "da3",
631 BrowseSource::Da2 => "da2",
632 BrowseSource::Flat => "flat",
633 BrowseSource::Derived => "derived",
634 }
635}
636
637#[utoipa::path(
640 get,
641 path = "/api/opc/browse",
642 tag = "opc",
643 params(OpcBrowseQuery),
644 responses(
645 (status = 200, body = OpcBrowseResponse),
646 (status = 400, description = "No OPC server was specified, the browse state is invalid, or the gateway could not be reached.", body = ErrorBody),
647 ),
648)]
649pub(crate) async fn browse(
650 State(state): State<AppState>,
651 Query(query): Query<OpcBrowseQuery>,
652) -> Result<Json<OpcBrowseResponse>, ApiError> {
653 let config = state.config_snapshot()?;
654 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
655 let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
656 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
657 let page_size = validate_positive(query.page_size, "page_size")?;
658 let driver = with_timeout(
659 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
660 OpcDaDriver::connect(&bridge_host, opc_server.clone()),
661 )
662 .await?;
663 let request = BrowsePageRequest {
664 session_id: query.session_id,
665 parent_node_key: query.parent_node_key,
666 page_token: query.page_token,
667 page_size,
668 refresh: query.refresh.unwrap_or(false),
669 };
670 let page = with_timeout("browse OPC DA namespace", driver.browse(request)).await?;
671 Ok(Json(page.into()))
672}
673
674#[derive(Debug, Serialize, ToSchema)]
675pub struct OpcCloseBrowseSessionResponse {
676 pub closed: bool,
677}
678
679#[utoipa::path(
680 delete,
681 path = "/api/opc/browse/sessions/{session_id}",
682 tag = "opc",
683 params(
684 ("session_id" = String, Path, description = "Opaque bridge browse-session ID."),
685 OpcServerQuery
686 ),
687 responses(
688 (status = 200, body = OpcCloseBrowseSessionResponse),
689 (status = 400, description = "The browse session could not be closed.", body = ErrorBody),
690 ),
691)]
692pub(crate) async fn close_browse_session(
693 State(state): State<AppState>,
694 Path(session_id): Path<String>,
695 Query(query): Query<OpcServerQuery>,
696) -> Result<Json<OpcCloseBrowseSessionResponse>, ApiError> {
697 if session_id.trim().is_empty() {
698 return Err(ApiError::BadRequest(
699 "a browse session ID is required".to_string(),
700 ));
701 }
702 let config = state.config_snapshot()?;
703 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
704 let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
705 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
706 let driver = with_timeout(
707 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
708 OpcDaDriver::connect(&bridge_host, opc_server),
709 )
710 .await?;
711 with_timeout(
712 "close OPC browse session",
713 driver.close_browse_session(&session_id),
714 )
715 .await?;
716 Ok(Json(OpcCloseBrowseSessionResponse { closed: true }))
717}
718
719#[derive(Debug, Deserialize, IntoParams)]
721#[into_params(parameter_in = Query)]
722pub struct OpcSearchQuery {
723 pub bridge_host: Option<String>,
724 pub opc_server: Option<String>,
725 pub query: String,
726 #[serde(default = "default_search_match_mode")]
727 pub match_mode: String,
728 pub session_id: Option<String>,
729 pub scope_node_key: Option<String>,
730 #[serde(default = "default_search_max_results")]
731 #[param(minimum = 1)]
732 pub max_results: u32,
733 pub include_branches: Option<bool>,
734 pub refresh: Option<bool>,
735}
736
737fn default_search_match_mode() -> String {
738 "contains".to_string()
739}
740
741fn default_search_max_results() -> u32 {
742 bhtune_driver::DEFAULT_SEARCH_MAX_RESULTS
743}
744
745fn parse_search_match_mode(value: &str) -> Result<SearchMatchMode, ApiError> {
746 match value {
747 "exact" => Ok(SearchMatchMode::Exact),
748 "prefix" => Ok(SearchMatchMode::Prefix),
749 "contains" => Ok(SearchMatchMode::Contains),
750 _ => Err(ApiError::BadRequest(
751 "match_mode must be one of: exact, prefix, contains".to_string(),
752 )),
753 }
754}
755
756fn search_event_to_sse(event: SearchEvent) -> Event {
757 let (kind, payload) = match event {
758 SearchEvent::Match(found) => ("match", json_search_match(&found)),
759 SearchEvent::Progress(progress) => (
760 "progress",
761 serde_json::json!({
762 "visited_nodes": progress.visited_nodes,
763 "matches": progress.matches,
764 "partial": progress.partial,
765 }),
766 ),
767 SearchEvent::Completed(completed) => (
768 "completed",
769 serde_json::json!({
770 "complete": completed.complete,
771 "cancelled": completed.cancelled,
772 "truncated": completed.truncated,
773 "warning": completed.warning,
774 }),
775 ),
776 };
777 Event::default().event(kind).data(payload.to_string())
778}
779
780fn json_search_match(found: &SearchMatch) -> serde_json::Value {
781 serde_json::json!({
782 "node": {
783 "node_key": found.node.node_key,
784 "display_name": found.node.display_name,
785 "kind": browse_node_kind_name(found.node.kind),
786 "item_id": found.node.item_id,
787 },
788 "breadcrumbs": found.breadcrumbs.iter().map(|part| {
789 serde_json::json!({
790 "node_key": part.node_key,
791 "display_name": part.display_name,
792 })
793 }).collect::<Vec<_>>(),
794 })
795}
796
797fn browse_node_kind_name(kind: BrowseNodeKind) -> &'static str {
798 match kind {
799 BrowseNodeKind::Unspecified => "unspecified",
800 BrowseNodeKind::Branch => "branch",
801 BrowseNodeKind::Item => "item",
802 BrowseNodeKind::BranchAndItem => "branch_and_item",
803 }
804}
805
806#[utoipa::path(
807 get,
808 path = "/api/opc/search",
809 tag = "opc",
810 params(OpcSearchQuery),
811 responses(
812 (status = 200, description = "SSE stream of match, progress, and completed events."),
813 (status = 400, description = "The search request or gateway connection is invalid.", body = ErrorBody),
814 ),
815)]
816pub(crate) async fn search(
817 State(state): State<AppState>,
818 Query(query): Query<OpcSearchQuery>,
819) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
820 if query.query.trim().is_empty() {
821 return Err(ApiError::BadRequest(
822 "a search query is required".to_string(),
823 ));
824 }
825 let match_mode = parse_search_match_mode(&query.match_mode)?;
826 let max_results = validate_positive(query.max_results, "max_results")?;
827 let config = state.config_snapshot()?;
828 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
829 let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
830 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
831 let driver = with_timeout(
832 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
833 OpcDaDriver::connect(&bridge_host, opc_server.clone()),
834 )
835 .await?;
836 let request = SearchRequest {
837 query: query.query,
838 match_mode,
839 session_id: query.session_id,
840 scope_node_key: query.scope_node_key,
841 max_results,
842 include_branches: query.include_branches.unwrap_or(false),
843 refresh: query.refresh.unwrap_or(false),
844 };
845 let mut stream =
846 with_timeout("start OPC namespace search", driver.search_stream(request)).await?;
847 let events = async_stream::stream! {
848 loop {
849 match tokio::time::timeout(
850 Duration::from_secs(OPC_QUERY_TIMEOUT_SECS),
851 stream.next(),
852 )
853 .await
854 {
855 Ok(Ok(Some(event))) => yield Ok(search_event_to_sse(event)),
856 Ok(Ok(None)) => break,
857 Ok(Err(error)) => {
858 yield Ok(Event::default().event("error").data(
859 serde_json::json!({"error": error.to_string()}).to_string(),
860 ));
861 break;
862 }
863 Err(_) => {
864 yield Ok(Event::default().event("error").data(
865 serde_json::json!({
866 "error": format!(
867 "namespace search: no response within {OPC_QUERY_TIMEOUT_SECS}s"
868 )
869 }).to_string(),
870 ));
871 break;
872 }
873 }
874 }
875 };
876 Ok(Sse::new(events).keep_alive(KeepAlive::default()))
877}
878
879#[derive(Debug, Deserialize, IntoParams)]
881#[into_params(parameter_in = Query)]
882pub struct OpcReadQuery {
883 pub bridge_host: Option<String>,
884 pub opc_server: Option<String>,
885 pub tag: Option<String>,
888}
889
890#[derive(Debug, Serialize, ToSchema)]
898pub struct OpcReadResponse {
899 pub tag: String,
900 pub value: String,
901 pub quality: SampleQuality,
902 pub timestamp: Option<DateTime<Utc>>,
909}
910
911#[utoipa::path(
919 get,
920 path = "/api/opc/read",
921 tag = "opc",
922 params(OpcReadQuery),
923 responses(
924 (status = 200, body = OpcReadResponse),
925 (status = 400, description = "No tag or OPC server was specified (and none is configured), or the gateway/read call could not be reached in time.", body = ErrorBody),
926 ),
927)]
928pub(crate) async fn read(
929 State(state): State<AppState>,
930 Query(query): Query<OpcReadQuery>,
931) -> Result<Json<OpcReadResponse>, ApiError> {
932 let tag = query
933 .tag
934 .filter(|t| !t.trim().is_empty())
935 .ok_or_else(|| ApiError::BadRequest("a tag is required".to_string()))?;
936 let config = state.config_snapshot()?;
937 let bridge_host = bhtune_cli::config::resolve_bridge_host(query.bridge_host, &config);
938 let opc_server = bhtune_cli::config::resolve_server(query.opc_server, &config)
939 .map_err(|err| ApiError::BadRequest(err.to_string()))?;
940 let driver = with_timeout(
941 &format!("connect to OPC server '{opc_server}' via bridge '{bridge_host}'"),
942 OpcDaDriver::connect(&bridge_host, opc_server.clone()),
943 )
944 .await?;
945 let values = with_timeout(
946 &format!("read '{tag}'"),
947 driver.read(std::slice::from_ref(&tag)),
948 )
949 .await?;
950 let value = values.into_iter().next().ok_or_else(|| {
951 ApiError::Internal(anyhow::anyhow!("driver returned no value for tag '{tag}'"))
952 })?;
953 Ok(Json(OpcReadResponse {
954 tag: value.tag,
955 value: value.value,
956 quality: sample_quality_from_driver(value.quality),
957 timestamp: value.timestamp,
958 }))
959}
960
961pub fn router() -> Router<AppState> {
962 Router::new()
963 .route("/api/opc/servers", get(servers))
964 .route("/api/opc/capabilities", get(capabilities))
965 .route("/api/opc/browse", get(browse))
966 .route(
967 "/api/opc/browse/sessions/{session_id}",
968 delete(close_browse_session),
969 )
970 .route("/api/opc/search", get(search))
971 .route("/api/opc/search-index/status", get(search_index_status))
972 .route("/api/opc/search-index/search", get(search_index))
973 .route("/api/opc/search-index/refresh", post(refresh_search_index))
974 .route(
975 "/api/opc/search-index/auto-refresh",
976 post(set_search_index_auto_refresh),
977 )
978 .route("/api/opc/search-index/control", post(control_search_index))
979 .route("/api/opc/search-index", delete(delete_search_index))
980 .route("/api/opc/read", get(read))
981}
982
983#[cfg(test)]
984mod tests {
985 use super::*;
986 use crate::test_support::mock_bridge::{MockBridgeService, start_mock_server};
987 use axum::body::{Body, to_bytes};
988 use axum::http::{Request, StatusCode};
989 use opcda_bridge_proto::bridge::{
990 BrowseNode as ProtoBrowseNode, BrowseNodeKind as ProtoBrowseNodeKind, BrowsePage,
991 BrowseSource as ProtoBrowseSource,
992 IndexSchedulerDiagnostics as ProtoIndexSchedulerDiagnostics,
993 IndexedSearchMatch as ProtoIndexedSearchMatch,
994 IndexedSearchProgress as ProtoIndexedSearchProgress, ListServersResponse,
995 NamespaceOrganization as ProtoNamespaceOrganization, ReadResponse,
996 SearchIndexResponse as ProtoSearchIndexResponse, SearchIndexState as ProtoSearchIndexState,
997 SearchIndexStatus as ProtoSearchIndexStatus, SearchMatchMode as ProtoSearchMatchMode,
998 TagValue as ProtoTagValue,
999 };
1000 use tonic::Code;
1001 use tower::ServiceExt;
1002
1003 async fn body_json(response: axum::http::Response<Body>) -> serde_json::Value {
1004 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1005 serde_json::from_slice(&bytes).unwrap()
1006 }
1007
1008 async fn get(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1009 app.oneshot(Request::get(path).body(Body::empty()).unwrap())
1010 .await
1011 .unwrap()
1012 }
1013
1014 async fn post(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1015 app.oneshot(Request::post(path).body(Body::empty()).unwrap())
1016 .await
1017 .unwrap()
1018 }
1019
1020 async fn delete_request(app: axum::Router, path: &str) -> axum::http::Response<Body> {
1021 app.oneshot(Request::delete(path).body(Body::empty()).unwrap())
1022 .await
1023 .unwrap()
1024 }
1025
1026 fn proto_index_status(state: ProtoSearchIndexState) -> ProtoSearchIndexStatus {
1027 ProtoSearchIndexStatus {
1028 server: "Sim.Server".to_string(),
1029 state: state as i32,
1030 configured: true,
1031 active_generation: 7,
1032 entry_count: 12_345,
1033 unique_item_count: 9_876,
1034 started_at: Some("2026-08-16T10:00:00Z".to_string()),
1035 completed_at: Some("2026-08-16T10:05:00Z".to_string()),
1036 last_error: None,
1037 database_bytes: 65_536,
1038 organization: ProtoNamespaceOrganization::Hierarchical as i32,
1039 source: ProtoBrowseSource::Da3 as i32,
1040 effective_limits: None,
1041 controller_state: 0,
1042 pause_reason: None,
1043 recovery_deadline: None,
1044 foreground: Default::default(),
1045 host: Default::default(),
1046 storage: Default::default(),
1047 scheduler: Some(ProtoIndexSchedulerDiagnostics {
1048 next_refresh_at: Some("2026-08-23T10:05:00Z".to_string()),
1049 last_attempt_at: Some("2026-08-16T10:05:00Z".to_string()),
1050 last_success_at: Some("2026-08-16T10:05:00Z".to_string()),
1051 last_success_duration_ms: Some(300_000),
1052 retry_after: None,
1053 consecutive_failures: 0,
1054 circuit_open: false,
1055 }),
1056 health: Default::default(),
1057 promoting: false,
1058 pause_reason_detail: None,
1059 progress: Some(ProtoIndexedSearchProgress {
1060 branches_visited: 321,
1061 entries_seen: 12_345,
1062 unique_items: 9_876,
1063 active_time_ms: 240_000,
1064 paused_time_ms: 60_000,
1065 items_per_second: 250.5,
1066 estimated_remaining_ms: Some(30_000),
1067 }),
1068 }
1069 }
1070
1071 fn expect_bad_request(error: ApiError) -> String {
1072 match error {
1073 ApiError::BadRequest(message) => message,
1074 other => panic!("expected BadRequest, got {other:?}"),
1075 }
1076 }
1077
1078 #[test]
1079 fn opc_conversion_helpers_cover_all_enum_variants_and_sse_events() {
1080 for (wire, expected) in [
1081 (BrowseNodeKind::Unspecified, "unspecified"),
1082 (BrowseNodeKind::Branch, "branch"),
1083 (BrowseNodeKind::Item, "item"),
1084 (BrowseNodeKind::BranchAndItem, "branch_and_item"),
1085 ] {
1086 assert_eq!(browse_node_kind_name(wire), expected);
1087 let _ = OpcBrowseNodeKind::from(wire);
1088 }
1089 for (value, expected) in [
1090 (NamespaceOrganization::Unspecified, "unspecified"),
1091 (NamespaceOrganization::Flat, "flat"),
1092 (NamespaceOrganization::Hierarchical, "hierarchical"),
1093 ] {
1094 assert_eq!(organization_name(value), expected);
1095 }
1096 for (value, expected) in [
1097 (BrowseSource::Unspecified, "unspecified"),
1098 (BrowseSource::Da3, "da3"),
1099 (BrowseSource::Da2, "da2"),
1100 (BrowseSource::Flat, "flat"),
1101 (BrowseSource::Derived, "derived"),
1102 ] {
1103 assert_eq!(source_name(value), expected);
1104 }
1105 for value in ["exact", "prefix", "contains"] {
1106 assert!(parse_search_match_mode(value).is_ok());
1107 }
1108 let events = [
1109 SearchEvent::Match(SearchMatch {
1110 node: BrowseNode {
1111 node_key: "n".into(),
1112 display_name: "PV".into(),
1113 kind: BrowseNodeKind::Item,
1114 item_id: Some("Area.PV".into()),
1115 },
1116 breadcrumbs: vec![],
1117 }),
1118 SearchEvent::Progress(bhtune_driver::SearchProgress {
1119 visited_nodes: 2,
1120 matches: 1,
1121 partial: true,
1122 }),
1123 SearchEvent::Completed(bhtune_driver::SearchCompleted {
1124 complete: true,
1125 cancelled: false,
1126 truncated: false,
1127 warning: None,
1128 }),
1129 ];
1130 for event in events {
1131 let rendered = search_event_to_sse(event);
1132 assert!(!format!("{rendered:?}").is_empty());
1133 }
1134 let json = json_search_match(&SearchMatch {
1135 node: BrowseNode {
1136 node_key: "n".into(),
1137 display_name: "PV".into(),
1138 kind: BrowseNodeKind::BranchAndItem,
1139 item_id: Some("Area.PV".into()),
1140 },
1141 breadcrumbs: vec![bhtune_driver::BrowseBreadcrumb {
1142 node_key: "area".into(),
1143 display_name: "Area".into(),
1144 }],
1145 });
1146 assert_eq!(json["breadcrumbs"][0]["display_name"], "Area");
1147 }
1148
1149 async fn state_with(bridge_host: Option<&str>, opc_server: Option<&str>) -> AppState {
1153 let state = crate::test_support::in_memory_state().await;
1154 let mut store = state.config_store.write().unwrap();
1155 store.config.bridge_host = bridge_host.map(str::to_string);
1156 store.config.server = opc_server.map(str::to_string);
1157 drop(store);
1158 state
1159 }
1160
1161 #[tokio::test]
1162 async fn servers_returns_every_registered_server_from_a_mock_gateway() {
1163 let host = start_mock_server(MockBridgeService {
1164 list_servers_response: ListServersResponse {
1165 servers: vec![
1166 "Matrikon.OPC.Simulation.1".to_string(),
1167 "Kepware.KEPServerEX.V6".to_string(),
1168 ],
1169 },
1170 ..Default::default()
1171 })
1172 .await;
1173 let app = crate::build_router(state_with(Some(&host), None).await);
1174
1175 let response = get(app, "/api/opc/servers").await;
1176 assert_eq!(response.status(), StatusCode::OK);
1177 let body = body_json(response).await;
1178 assert_eq!(
1179 body["servers"],
1180 serde_json::json!(["Matrikon.OPC.Simulation.1", "Kepware.KEPServerEX.V6"])
1181 );
1182 }
1183
1184 #[tokio::test]
1185 async fn servers_handles_an_empty_result() {
1186 let host = start_mock_server(MockBridgeService::default()).await;
1187 let app = crate::build_router(state_with(Some(&host), None).await);
1188
1189 let response = get(app, "/api/opc/servers").await;
1190 assert_eq!(response.status(), StatusCode::OK);
1191 assert_eq!(
1192 body_json(response).await,
1193 serde_json::json!({"servers": []})
1194 );
1195 }
1196
1197 #[tokio::test]
1198 async fn servers_returns_400_when_the_gateway_is_unreachable() {
1199 let app = crate::build_router(state_with(Some("127.0.0.1:1"), None).await);
1200
1201 let response = get(app, "/api/opc/servers").await;
1202 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1203 let body = body_json(response).await;
1204 assert!(
1205 body["error"]
1206 .as_str()
1207 .unwrap()
1208 .contains("list OPC DA servers")
1209 );
1210 }
1211
1212 #[tokio::test]
1213 async fn servers_query_param_overrides_the_configured_bridge_host() {
1214 let host = start_mock_server(MockBridgeService::default()).await;
1215 let app = crate::build_router(state_with(Some("127.0.0.1:1"), None).await);
1217
1218 let response = get(app, &format!("/api/opc/servers?bridge_host={host}")).await;
1219 assert_eq!(response.status(), StatusCode::OK);
1220 }
1221
1222 #[tokio::test]
1223 async fn capabilities_reports_the_indexed_search_contract() {
1224 let host = start_mock_server(MockBridgeService {
1225 capabilities_response: opcda_bridge_proto::bridge::GetCapabilitiesResponse {
1226 application_version: "0.4.0".to_string(),
1227 protocol_version: "2".to_string(),
1228 max_page_size: 1000,
1229 supports_browse_sessions: true,
1230 supports_search: true,
1231 organization: ProtoNamespaceOrganization::Hierarchical as i32,
1232 source: ProtoBrowseSource::Da3 as i32,
1233 supports_indexed_search: true,
1234 indexed_search_protocol_version: "1".to_string(),
1235 max_indexed_search_results: 50,
1236 search_index_state: ProtoSearchIndexState::Ready as i32,
1237 search_index_promoting: false,
1238 },
1239 ..Default::default()
1240 })
1241 .await;
1242 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1243
1244 let response = get(app, "/api/opc/capabilities").await;
1245 assert_eq!(response.status(), StatusCode::OK);
1246 let body = body_json(response).await;
1247 assert_eq!(body["application_version"], "0.4.0");
1248 assert_eq!(body["protocol_version"], "2");
1249 assert_eq!(body["supports_indexed_search"], true);
1250 assert_eq!(body["indexed_search_protocol_version"], "1");
1251 assert_eq!(body["max_indexed_search_results"], 50);
1252 assert_eq!(body["search_index_state"], "ready");
1253 }
1254
1255 #[tokio::test]
1256 async fn search_index_status_maps_every_state_and_progress() {
1257 let states = [
1258 (ProtoSearchIndexState::Unspecified, "unspecified"),
1259 (ProtoSearchIndexState::NotIndexed, "not_indexed"),
1260 (ProtoSearchIndexState::Partial, "partial"),
1261 (ProtoSearchIndexState::Ready, "ready"),
1262 (ProtoSearchIndexState::Stale, "stale"),
1263 (ProtoSearchIndexState::Refreshing, "refreshing"),
1264 (ProtoSearchIndexState::Failed, "failed"),
1265 ];
1266
1267 for (state, expected_state) in states {
1268 let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1269 let host = start_mock_server(MockBridgeService {
1270 search_index_status_response: proto_index_status(state),
1271 search_index_status_requests: requests.clone(),
1272 ..Default::default()
1273 })
1274 .await;
1275 let app = crate::build_router(state_with(Some(&host), None).await);
1276
1277 let response = get(app, "/api/opc/search-index/status?opc_server=Sim.Server").await;
1278 assert_eq!(response.status(), StatusCode::OK);
1279 let body = body_json(response).await;
1280 assert_eq!(body["server"], "Sim.Server");
1281 assert_eq!(body["state"], expected_state);
1282 assert_eq!(body["auto_refresh_enabled"], true);
1283 assert_eq!(body["active_generation"], 7);
1284 assert_eq!(body["entry_count"], 12_345);
1285 assert_eq!(body["unique_item_count"], 9_876);
1286 assert_eq!(body["started_at"], "2026-08-16T10:00:00Z");
1287 assert_eq!(body["completed_at"], "2026-08-16T10:05:00Z");
1288 assert_eq!(body["database_bytes"], 65_536);
1289 assert_eq!(body["organization"], "hierarchical");
1290 assert_eq!(body["source"], "da3");
1291 assert_eq!(body["scheduler"]["next_refresh_at"], "2026-08-23T10:05:00Z");
1292 assert_eq!(body["scheduler"]["last_attempt_at"], "2026-08-16T10:05:00Z");
1293 assert_eq!(body["scheduler"]["last_success_at"], "2026-08-16T10:05:00Z");
1294 assert_eq!(body["scheduler"]["last_success_duration_ms"], 300_000);
1295 assert_eq!(body["scheduler"]["consecutive_failures"], 0);
1296 assert_eq!(body["scheduler"]["circuit_open"], false);
1297 assert_eq!(body["progress"]["branches_visited"], 321);
1298 assert_eq!(body["progress"]["entries_seen"], 12_345);
1299 assert_eq!(body["progress"]["unique_items"], 9_876);
1300 assert_eq!(body["progress"]["active_time_ms"], 240_000);
1301 assert_eq!(body["progress"]["paused_time_ms"], 60_000);
1302 assert_eq!(body["progress"]["items_per_second"], 250.5);
1303 assert_eq!(body["progress"]["estimated_remaining_ms"], 30_000);
1304 assert_eq!(
1305 requests.lock().unwrap().as_slice(),
1306 &[opcda_bridge_proto::bridge::GetSearchIndexStatusRequest {
1307 server: "Sim.Server".to_string(),
1308 }]
1309 );
1310 }
1311 }
1312
1313 #[tokio::test]
1314 async fn search_index_returns_exact_matches_status_and_has_more() {
1315 let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1316 let host = start_mock_server(MockBridgeService {
1317 search_index_response: ProtoSearchIndexResponse {
1318 matches: vec![ProtoIndexedSearchMatch {
1319 item_id: "FCS0201!204FI00510.PV".to_string(),
1320 display_name: "PV".to_string(),
1321 kind: ProtoBrowseNodeKind::BranchAndItem as i32,
1322 breadcrumbs: vec!["FCS0201".to_string(), "204FI00510".to_string()],
1323 }],
1324 has_more: true,
1325 status: Some(proto_index_status(ProtoSearchIndexState::Ready)),
1326 },
1327 search_index_requests: requests.clone(),
1328 ..Default::default()
1329 })
1330 .await;
1331 let app = crate::build_router(state_with(Some(&host), None).await);
1332
1333 let response = get(
1334 app,
1335 "/api/opc/search-index/search?opc_server=Sim.Server&query=204FI00510&match_mode=prefix&max_results=7",
1336 )
1337 .await;
1338 assert_eq!(response.status(), StatusCode::OK);
1339 let body = body_json(response).await;
1340 assert_eq!(
1341 body["matches"],
1342 serde_json::json!([{
1343 "item_id": "FCS0201!204FI00510.PV",
1344 "display_name": "PV",
1345 "kind": "branch_and_item",
1346 "breadcrumbs": ["FCS0201", "204FI00510"],
1347 }])
1348 );
1349 assert_eq!(body["has_more"], true);
1350 assert_eq!(body["status"]["state"], "ready");
1351
1352 let request = &requests.lock().unwrap()[0];
1353 assert_eq!(request.server, "Sim.Server");
1354 assert_eq!(request.query, "204FI00510");
1355 assert_eq!(request.match_mode, ProtoSearchMatchMode::Prefix as i32);
1356 assert_eq!(request.max_results, 7);
1357 }
1358
1359 #[tokio::test]
1360 async fn search_index_refresh_and_control_forward_actions() {
1361 let refresh_requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1362 let control_requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1363 let host = start_mock_server(MockBridgeService {
1364 search_index_status_response: proto_index_status(ProtoSearchIndexState::Refreshing),
1365 refresh_search_index_requests: refresh_requests.clone(),
1366 control_search_index_requests: control_requests.clone(),
1367 ..Default::default()
1368 })
1369 .await;
1370
1371 let refresh_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1372 let response = post(refresh_app, "/api/opc/search-index/refresh?force=true").await;
1373 assert_eq!(response.status(), StatusCode::OK);
1374 assert_eq!(body_json(response).await["state"], "refreshing");
1375 assert_eq!(
1376 refresh_requests.lock().unwrap().as_slice(),
1377 &[opcda_bridge_proto::bridge::RefreshSearchIndexRequest {
1378 server: "Sim.Server".to_string(),
1379 force: true,
1380 }]
1381 );
1382
1383 let control_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1384 let response = post(control_app, "/api/opc/search-index/control?action=resume").await;
1385 assert_eq!(response.status(), StatusCode::OK);
1386 assert_eq!(body_json(response).await["state"], "refreshing");
1387 assert_eq!(
1388 control_requests.lock().unwrap().as_slice(),
1389 &[opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1390 server: "Sim.Server".to_string(),
1391 action: opcda_bridge_proto::bridge::SearchIndexControlAction::Resume as i32,
1392 }]
1393 );
1394
1395 let auto_refresh_app =
1396 crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1397 let response = post(
1398 auto_refresh_app,
1399 "/api/opc/search-index/auto-refresh?enabled=false",
1400 )
1401 .await;
1402 assert_eq!(response.status(), StatusCode::OK);
1403 assert_eq!(body_json(response).await["state"], "refreshing");
1404
1405 let delete_app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1406 let response =
1407 delete_request(delete_app, "/api/opc/search-index?opc_server=Sim.Server").await;
1408 assert_eq!(response.status(), StatusCode::OK);
1409 assert_eq!(body_json(response).await["state"], "refreshing");
1410
1411 assert_eq!(
1412 control_requests.lock().unwrap().as_slice(),
1413 &[
1414 opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1415 server: "Sim.Server".to_string(),
1416 action: opcda_bridge_proto::bridge::SearchIndexControlAction::Resume as i32,
1417 },
1418 opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1419 server: "Sim.Server".to_string(),
1420 action: opcda_bridge_proto::bridge::SearchIndexControlAction::DisableAutoRefresh
1421 as i32,
1422 },
1423 opcda_bridge_proto::bridge::ControlSearchIndexRequest {
1424 server: "Sim.Server".to_string(),
1425 action: opcda_bridge_proto::bridge::SearchIndexControlAction::Delete as i32,
1426 },
1427 ]
1428 );
1429 }
1430
1431 #[tokio::test]
1432 async fn indexed_search_routes_reject_invalid_input_before_connecting() {
1433 let cases = [
1434 (
1435 "/api/opc/search-index/search?query=%20&opc_server=Sim.Server",
1436 "search query is required",
1437 ),
1438 (
1439 "/api/opc/search-index/search?query=PV&match_mode=wildcard&opc_server=Sim.Server",
1440 "match_mode must be one of",
1441 ),
1442 (
1443 "/api/opc/search-index/search?query=PV&max_results=0&opc_server=Sim.Server",
1444 "max_results must be greater than zero",
1445 ),
1446 (
1447 "/api/opc/search-index/control?action=stop&opc_server=Sim.Server",
1448 "action must be one of",
1449 ),
1450 ];
1451
1452 for (path, expected_error) in cases {
1453 let app = crate::build_router(state_with(None, None).await);
1454 let response = if path.contains("/control") {
1455 post(app, path).await
1456 } else {
1457 get(app, path).await
1458 };
1459 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1460 let body = body_json(response).await;
1461 assert!(body["error"].as_str().unwrap().contains(expected_error));
1462 }
1463 }
1464
1465 #[tokio::test]
1466 async fn indexed_search_routes_surface_gateway_errors() {
1467 let host = start_mock_server(MockBridgeService {
1468 search_index_status_error: Some(tonic::Status::internal("status failed")),
1469 search_index_error: Some(tonic::Status::internal("search failed")),
1470 refresh_search_index_error: Some(tonic::Status::internal("refresh failed")),
1471 control_search_index_error: Some(tonic::Status::internal("control failed")),
1472 ..Default::default()
1473 })
1474 .await;
1475 let paths = [
1476 (
1477 "/api/opc/search-index/status?opc_server=Sim.Server",
1478 "read OPC search-index status",
1479 false,
1480 ),
1481 (
1482 "/api/opc/search-index/search?query=PV&opc_server=Sim.Server",
1483 "search the OPC namespace index",
1484 false,
1485 ),
1486 (
1487 "/api/opc/search-index/refresh?opc_server=Sim.Server",
1488 "refresh the OPC namespace index",
1489 true,
1490 ),
1491 (
1492 "/api/opc/search-index/control?action=pause&opc_server=Sim.Server",
1493 "control the OPC namespace index",
1494 true,
1495 ),
1496 (
1497 "/api/opc/search-index/auto-refresh?enabled=false&opc_server=Sim.Server",
1498 "set OPC namespace index auto-refresh",
1499 true,
1500 ),
1501 (
1502 "/api/opc/search-index?opc_server=Sim.Server",
1503 "delete the OPC namespace index",
1504 false,
1505 ),
1506 ];
1507
1508 for (path, operation, is_post) in paths {
1509 let app = crate::build_router(state_with(Some(&host), None).await);
1510 let response = if path.starts_with("/api/opc/search-index?") {
1511 delete_request(app, path).await
1512 } else if is_post {
1513 post(app, path).await
1514 } else {
1515 get(app, path).await
1516 };
1517 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1518 let error = body_json(response).await["error"]
1519 .as_str()
1520 .unwrap()
1521 .to_string();
1522 assert!(error.contains(operation), "{error}");
1523 assert!(error.contains("failed"), "{error}");
1524 }
1525 }
1526
1527 #[tokio::test]
1528 async fn indexed_search_status_reports_an_unsupported_gateway() {
1529 let host = start_mock_server(MockBridgeService {
1530 search_index_status_error: Some(tonic::Status::new(
1531 Code::Unimplemented,
1532 "indexed search is unavailable",
1533 )),
1534 ..Default::default()
1535 })
1536 .await;
1537 let app = crate::build_router(state_with(Some(&host), None).await);
1538
1539 let response = get(app, "/api/opc/search-index/status?opc_server=Sim.Server").await;
1540 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1541 let error = body_json(response).await["error"]
1542 .as_str()
1543 .unwrap()
1544 .to_string();
1545 assert!(error.contains("does not support indexed-search status"));
1546 assert!(
1547 error.contains("upgrade the OPC DA bridge gateway"),
1548 "{error}"
1549 );
1550 }
1551
1552 #[tokio::test]
1553 async fn browse_returns_nodes_from_a_mock_gateway() {
1554 let host = start_mock_server(MockBridgeService {
1555 browse_response: BrowsePage {
1556 session_id: "session".to_string(),
1557 nodes: vec![ProtoBrowseNode {
1558 node_key: "unit1".to_string(),
1559 display_name: "Unit1".to_string(),
1560 kind: ProtoBrowseNodeKind::Branch as i32,
1561 item_id: None,
1562 }],
1563 complete: true,
1564 ..Default::default()
1565 },
1566 ..Default::default()
1567 })
1568 .await;
1569 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1570
1571 let response = get(app, "/api/opc/browse").await;
1572 assert_eq!(response.status(), StatusCode::OK);
1573 let body = body_json(response).await;
1574 assert_eq!(body["session_id"], "session");
1575 assert_eq!(body["nodes"][0]["display_name"], "Unit1");
1576 assert_eq!(body["nodes"][0]["kind"], "branch");
1577 }
1578
1579 #[tokio::test]
1580 async fn browse_handles_an_empty_result() {
1581 let host = start_mock_server(MockBridgeService::default()).await;
1582 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1583
1584 let response = get(
1585 app,
1586 "/api/opc/browse?session_id=session&parent_node_key=unit1",
1587 )
1588 .await;
1589 assert_eq!(response.status(), StatusCode::OK);
1590 let body = body_json(response).await;
1591 assert_eq!(body["nodes"], serde_json::json!([]));
1592 assert_eq!(body["complete"], true);
1593 }
1594
1595 #[tokio::test]
1596 async fn browse_returns_400_when_no_server_is_configured() {
1597 let app = crate::build_router(state_with(None, None).await);
1598
1599 let response = get(app, "/api/opc/browse").await;
1600 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1601 let body = body_json(response).await;
1602 assert!(
1603 body["error"]
1604 .as_str()
1605 .unwrap()
1606 .contains("no OPC server specified")
1607 );
1608 }
1609
1610 #[tokio::test]
1611 async fn browse_returns_400_when_the_gateway_is_unreachable() {
1612 let app = crate::build_router(state_with(Some("127.0.0.1:1"), Some("Sim.Server")).await);
1613
1614 let response = get(app, "/api/opc/browse").await;
1615 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1616 let body = body_json(response).await;
1617 assert!(
1618 body["error"]
1619 .as_str()
1620 .unwrap()
1621 .contains("connect to OPC server")
1622 );
1623 }
1624
1625 #[tokio::test]
1626 async fn close_browse_session_forwards_the_opaque_session_id() {
1627 let host = start_mock_server(MockBridgeService::default()).await;
1628 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1629 let response = get(
1630 app,
1631 "/api/opc/browse/sessions/session-42?opc_server=Sim.Server",
1632 )
1633 .await;
1634 assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
1636 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1637 let response = app
1638 .oneshot(
1639 Request::delete("/api/opc/browse/sessions/session-42?opc_server=Sim.Server")
1640 .body(Body::empty())
1641 .unwrap(),
1642 )
1643 .await
1644 .unwrap();
1645 assert_eq!(response.status(), StatusCode::OK);
1646 assert_eq!(body_json(response).await["closed"], true);
1647 }
1648
1649 #[tokio::test]
1650 async fn search_stream_returns_match_progress_and_completed_events() {
1651 let host = start_mock_server(MockBridgeService {
1652 search_events: vec![
1653 opcda_bridge_proto::bridge::SearchEvent {
1654 event: Some(opcda_bridge_proto::bridge::search_event::Event::Match(
1655 opcda_bridge_proto::bridge::SearchMatch {
1656 node: Some(ProtoBrowseNode {
1657 node_key: "pv".into(),
1658 display_name: "PV".into(),
1659 kind: ProtoBrowseNodeKind::Item as i32,
1660 item_id: Some("Area.PV".into()),
1661 }),
1662 breadcrumbs: vec![opcda_bridge_proto::bridge::BrowseBreadcrumb {
1663 node_key: "area".into(),
1664 display_name: "Area".into(),
1665 }],
1666 },
1667 )),
1668 },
1669 opcda_bridge_proto::bridge::SearchEvent {
1670 event: Some(opcda_bridge_proto::bridge::search_event::Event::Progress(
1671 opcda_bridge_proto::bridge::SearchProgress {
1672 visited_nodes: 3,
1673 matches: 1,
1674 partial: false,
1675 },
1676 )),
1677 },
1678 opcda_bridge_proto::bridge::SearchEvent {
1679 event: Some(opcda_bridge_proto::bridge::search_event::Event::Completed(
1680 opcda_bridge_proto::bridge::SearchCompleted {
1681 complete: true,
1682 cancelled: false,
1683 truncated: false,
1684 warning: None,
1685 },
1686 )),
1687 },
1688 ],
1689 ..Default::default()
1690 })
1691 .await;
1692 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1693 let response = get(
1694 app,
1695 "/api/opc/search?opc_server=Sim.Server&query=PV&match_mode=exact&max_results=4",
1696 )
1697 .await;
1698 assert_eq!(response.status(), StatusCode::OK);
1699 let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
1700 let text = String::from_utf8(body.to_vec()).unwrap();
1701 assert!(text.contains("event: match"));
1702 assert!(text.contains("event: progress"));
1703 assert!(text.contains("event: completed"));
1704 assert!(text.contains("Area.PV"));
1705 }
1706
1707 #[tokio::test]
1708 async fn read_returns_the_value_quality_and_timestamp_from_a_mock_gateway() {
1709 let host = start_mock_server(MockBridgeService {
1710 read_response: ReadResponse {
1715 values: vec![ProtoTagValue {
1716 tag_id: "Unit1.LIC101.PV".to_string(),
1717 value: "42.5".to_string(),
1718 quality: "Good".to_string(),
1719 timestamp: "2024-01-15 10:23:45".to_string(),
1720 }],
1721 },
1722 ..Default::default()
1723 })
1724 .await;
1725 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1726
1727 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1728 assert_eq!(response.status(), StatusCode::OK);
1729 let body = body_json(response).await;
1730 assert_eq!(body["tag"], "Unit1.LIC101.PV");
1731 assert_eq!(body["value"], "42.5");
1732 assert_eq!(body["quality"], "good");
1733 assert!(body["timestamp"].is_null());
1738 }
1739
1740 #[tokio::test]
1741 async fn read_returns_500_when_the_driver_reports_success_but_no_value() {
1742 let host = start_mock_server(MockBridgeService {
1749 read_response: ReadResponse { values: vec![] },
1750 ..Default::default()
1751 })
1752 .await;
1753 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1754
1755 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1756 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1757 let body = body_json(response).await;
1758 assert_eq!(body["error"], "internal server error");
1762 }
1763
1764 #[tokio::test]
1765 async fn read_requires_a_tag() {
1766 let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1767
1768 let response = get(app, "/api/opc/read").await;
1769 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1770 let body = body_json(response).await;
1771 assert!(body["error"].as_str().unwrap().contains("tag is required"));
1772 }
1773
1774 #[tokio::test]
1775 async fn read_returns_400_when_no_server_is_configured() {
1776 let app = crate::build_router(state_with(None, None).await);
1777
1778 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1779 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1780 let body = body_json(response).await;
1781 assert!(
1782 body["error"]
1783 .as_str()
1784 .unwrap()
1785 .contains("no OPC server specified")
1786 );
1787 }
1788
1789 #[tokio::test]
1790 async fn read_returns_400_when_the_gateway_is_unreachable() {
1791 let app = crate::build_router(state_with(Some("127.0.0.1:1"), Some("Sim.Server")).await);
1792
1793 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1794 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1795 }
1796
1797 #[tokio::test]
1798 async fn close_browse_session_validates_empty_ids_before_connecting() {
1799 let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1800 let response = app
1801 .oneshot(
1802 Request::delete("/api/opc/browse/sessions/%20?opc_server=Sim.Server")
1803 .body(Body::empty())
1804 .unwrap(),
1805 )
1806 .await
1807 .unwrap();
1808 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1809 assert!(
1810 body_json(response).await["error"]
1811 .as_str()
1812 .unwrap()
1813 .contains("session ID")
1814 );
1815 }
1816
1817 #[tokio::test]
1818 async fn search_validates_empty_query_before_connecting() {
1819 let app = crate::build_router(state_with(None, Some("Sim.Server")).await);
1820 let response = get(app, "/api/opc/search?query=%20").await;
1821 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1822 assert!(
1823 body_json(response).await["error"]
1824 .as_str()
1825 .unwrap()
1826 .contains("search query")
1827 );
1828 }
1829
1830 #[tokio::test]
1831 async fn read_returns_400_when_the_connected_gateway_rejects_the_read() {
1832 let host = start_mock_server(MockBridgeService {
1833 read_error: Some(tonic::Status::unavailable("read unavailable")),
1834 ..Default::default()
1835 })
1836 .await;
1837 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1838
1839 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1840 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
1841 assert!(
1842 body_json(response).await["error"]
1843 .as_str()
1844 .unwrap()
1845 .contains("read 'Unit1.LIC101.PV'")
1846 );
1847 }
1848
1849 #[tokio::test]
1850 async fn read_surfaces_uncertain_and_bad_quality_without_failing() {
1851 let host = start_mock_server(MockBridgeService {
1852 read_response: opcda_bridge_proto::bridge::ReadResponse {
1853 values: vec![opcda_bridge_proto::bridge::TagValue {
1854 tag_id: "ignored".to_string(),
1855 value: "0".to_string(),
1856 quality: "Bad".to_string(),
1857 timestamp: "2024-01-15 10:23:45".to_string(),
1858 }],
1859 },
1860 ..Default::default()
1861 })
1862 .await;
1863 let app = crate::build_router(state_with(Some(&host), Some("Sim.Server")).await);
1864
1865 let response = get(app, "/api/opc/read?tag=Unit1.LIC101.PV").await;
1866 assert_eq!(response.status(), StatusCode::OK);
1867 let body = body_json(response).await;
1868 assert_eq!(body["quality"], "bad");
1869 }
1870
1871 #[tokio::test(start_paused = true)]
1877 async fn with_timeout_maps_an_elapsed_deadline_to_a_bad_request() {
1878 let err = with_timeout("read 'X'", std::future::pending::<DriverResult<()>>())
1879 .await
1880 .unwrap_err();
1881 let message = expect_bad_request(err);
1882 assert!(message.contains("read 'X'"));
1883 assert!(message.contains("no response within 30s"));
1884 }
1885
1886 #[tokio::test]
1887 async fn with_timeout_passes_through_a_successful_result() {
1888 let value = with_timeout("op", async { Ok::<_, bhtune_driver::DriverError>(7) })
1889 .await
1890 .unwrap();
1891 assert_eq!(value, 7);
1892 }
1893
1894 #[tokio::test]
1895 async fn with_timeout_maps_a_driver_error_to_a_bad_request() {
1896 let err = with_timeout("read 'X'", async {
1897 Err::<(), _>(bhtune_driver::DriverError::Unsupported {
1898 operation: "browse",
1899 })
1900 })
1901 .await
1902 .unwrap_err();
1903 let message = expect_bad_request(err);
1904 assert!(message.contains("read 'X'"));
1905 assert!(message.contains("not supported"));
1906 }
1907
1908 #[test]
1909 fn bad_request_assertion_fails_clearly_for_another_api_error() {
1910 let panic = std::panic::catch_unwind(|| {
1911 expect_bad_request(ApiError::NotFound("missing".to_string()))
1912 })
1913 .unwrap_err();
1914 assert!(
1915 panic
1916 .downcast_ref::<String>()
1917 .is_some_and(|message| message.contains("BadRequest"))
1918 );
1919 }
1920
1921 #[tokio::test]
1922 async fn with_timeout_preserves_index_enrollment_diagnostic() {
1923 let err = with_timeout("refresh the OPC namespace index", async {
1924 Err::<(), _>(bhtune_driver::DriverError::IndexOperationRejected {
1925 message: "server is not enrolled for namespace indexing".to_string(),
1926 })
1927 })
1928 .await
1929 .unwrap_err();
1930 match err {
1931 ApiError::BadRequest(message) => {
1932 assert!(message.contains("refresh the OPC namespace index"));
1933 assert!(message.contains("server is not enrolled for namespace indexing"));
1934 }
1935 other => panic!("expected BadRequest, got {other:?}"),
1936 }
1937 }
1938}