Skip to main content

appcore_api/
router.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: router.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/05/31 13:38:42 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/07/23 13:45:20 by dnettoRaw
8//      ###########      S: 1.0.1-rc.8
9// =============================================================================
10
11//! In-memory API router contracts.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use appcore_core::{RuntimeError, RuntimeResult};
17
18use crate::api::{ApiRequest, ApiResponse};
19use crate::command_endpoint::CommandEndpoint;
20use crate::query_endpoint::{QueryEndpoint, QueryName};
21
22/// Minimal cloneable router for one command endpoint and multiple query endpoints.
23///
24/// Clones share an immutable endpoint snapshot. Runtime hosts freeze query
25/// registration after bootstrap and clone the router before dispatch so no
26/// host-state lock is retained while an endpoint executes.
27#[derive(Clone, Default)]
28pub struct ApiRouter {
29    state: Arc<ApiRouterState>,
30}
31
32#[derive(Clone, Default)]
33struct ApiRouterState {
34    command_endpoint: Option<Arc<dyn CommandEndpoint>>,
35    queries: HashMap<QueryName, Arc<dyn QueryEndpoint>>,
36    queries_frozen: bool,
37}
38
39impl ApiRouter {
40    /// Creates an empty transport-neutral router.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Replaces the command endpoint used by this router.
46    pub fn set_command_endpoint<E: CommandEndpoint + 'static>(&mut self, endpoint: E) {
47        Arc::make_mut(&mut self.state).command_endpoint = Some(Arc::new(endpoint));
48    }
49
50    /// Registers one uniquely named application query endpoint.
51    ///
52    /// Registration fails after [`Self::freeze_queries`].
53    pub fn register_query<E: QueryEndpoint + 'static>(&mut self, endpoint: E) -> RuntimeResult<()> {
54        let name = endpoint.query_name().clone();
55        if self.state.queries_frozen {
56            return Err(RuntimeError::InvalidRequest {
57                kind: "query",
58                reason: "router_frozen",
59            });
60        }
61        if self.state.queries.contains_key(&name) {
62            return Err(RuntimeError::RegistryItemAlreadyRegistered {
63                kind: "query",
64                name: name.as_str().to_string(),
65            });
66        }
67        Arc::make_mut(&mut self.state)
68            .queries
69            .insert(name, Arc::new(endpoint));
70        Ok(())
71    }
72
73    /// Freezes application query registration while preserving dispatch.
74    pub fn freeze_queries(&mut self) {
75        Arc::make_mut(&mut self.state).queries_frozen = true;
76    }
77
78    /// Reports whether application query registration is frozen.
79    pub fn queries_are_frozen(&self) -> bool {
80        self.state.queries_frozen
81    }
82
83    /// Reports whether a query endpoint is registered.
84    pub fn has_query(&self, name: &QueryName) -> bool {
85        self.state.queries.contains_key(name)
86    }
87
88    /// Returns registered query names in deterministic lexical order.
89    pub fn query_names(&self) -> Vec<QueryName> {
90        let mut names = self.query_names_iter().cloned().collect::<Vec<_>>();
91        names.sort_by(|left, right| left.as_str().cmp(right.as_str()));
92        names
93    }
94
95    /// Iterates over registered query names without cloning or ordering them.
96    ///
97    /// Callers that expose names externally must impose their required stable
98    /// order. Validation paths can scan this view without materializing the
99    /// complete registry.
100    pub fn query_names_iter(&self) -> impl ExactSizeIterator<Item = &QueryName> {
101        self.state.queries.keys()
102    }
103
104    /// Dispatches a request to a named query endpoint.
105    pub fn dispatch_query(
106        &self,
107        name: &QueryName,
108        request: ApiRequest,
109    ) -> RuntimeResult<ApiResponse> {
110        let Some(endpoint) = self.state.queries.get(name) else {
111            return Err(RuntimeError::RegistryItemNotFound {
112                kind: "query",
113                name: name.as_str().to_string(),
114            });
115        };
116        endpoint.handle_query(request)
117    }
118
119    /// Dispatches a request to the configured command endpoint.
120    pub fn dispatch_command(&self, request: ApiRequest) -> RuntimeResult<ApiResponse> {
121        let Some(endpoint) = &self.state.command_endpoint else {
122            return Err(RuntimeError::MissingConfiguration {
123                name: "command_endpoint",
124            });
125        };
126        endpoint.handle_command(request)
127    }
128}
129
130#[cfg(test)]
131#[path = "router_tests.rs"]
132mod tests;