Skip to main content

rightkit_daemon/
service.rs

1//! `Service::builder()`: one [`Handler`] and one [`MethodTable`], hosted on the local transport
2//! and, with the `remote` feature, offered to signed devices as `POST /v1/call/{method}`.
3//!
4//! ```text
5//! let service = Service::builder()
6//!     .methods(MethodTable::new().method("echo", MethodPolicy::local(["app"]).and_remote(["venture"])))
7//!     .handler(Arc::new(MyHandler))
8//!     .local(&suite, ServiceConfig::new("svc", "role", 5, "1.0", Allowlist::new()), HostOptions::new())
9//!     .remote(DaemonConfig::new(state_dir))
10//!     .start()?;
11//! // Local callers use `Client`; serve `service.daemon()` over TLS with `remote::serve_blocking`.
12//! ```
13//!
14//! The method table decides which transport each method is on, so a `transport: both` method is
15//! reachable by [`Client::call`](crate::Client::call) and by a signed device with the same params
16//! and the handler's own error codes. Methods that are remote-only by nature (`upload.chunk`,
17//! `oauth.callback`, `webhook.inbound`) and the existing v1 routes keep their own routes.
18
19use std::sync::Arc;
20
21use crate::contract::{Handler, MethodTable, TableError};
22use crate::server::{host_with_options, HostError, HostOptions, Hosted, ServiceConfig};
23use crate::suite::Suite;
24
25#[cfg(feature = "remote")]
26use crate::remote::{Clock, Daemon, DaemonConfig, DeviceCalls, OAuthCallbackHandler, SystemClock};
27
28/// Why [`ServiceBuilder::start`] failed.
29#[derive(Debug)]
30pub enum BuildError {
31    /// A required part was not given (`methods`, `handler`).
32    Missing(&'static str),
33    /// Neither `local` nor `remote` was configured.
34    NoTransport,
35    /// The method table was refused.
36    Table(TableError),
37    /// The local host did not start.
38    Host(Box<HostError>),
39    /// The remote daemon did not open its state.
40    Io(std::io::Error),
41}
42
43impl std::fmt::Display for BuildError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        match self {
46            BuildError::Missing(what) => write!(f, "service builder: {what} not set"),
47            BuildError::NoTransport => f.write_str("service builder: no transport configured"),
48            BuildError::Table(e) => write!(f, "service builder: {e}"),
49            BuildError::Host(e) => write!(f, "service builder: local host: {e}"),
50            BuildError::Io(e) => write!(f, "service builder: remote state: {e}"),
51        }
52    }
53}
54
55impl std::error::Error for BuildError {}
56
57struct LocalSpec {
58    suite: Suite,
59    cfg: ServiceConfig,
60    opts: HostOptions,
61}
62
63#[cfg(feature = "remote")]
64struct RemoteSpec {
65    cfg: DaemonConfig,
66    clock: Arc<dyn Clock>,
67    oauth: Option<Arc<dyn OAuthCallbackHandler>>,
68}
69
70/// A running service: the local host, the remote daemon, or both, over one handler.
71pub struct Service {
72    table: MethodTable,
73    hosted: Option<Hosted>,
74    #[cfg(feature = "remote")]
75    daemon: Option<Arc<Daemon>>,
76}
77
78impl Service {
79    pub fn builder() -> ServiceBuilder {
80        ServiceBuilder::default()
81    }
82
83    pub fn methods(&self) -> &MethodTable {
84        &self.table
85    }
86
87    /// The local transport host, when configured. Dropping the service releases it.
88    pub fn local(&self) -> Option<&Hosted> {
89        self.hosted.as_ref()
90    }
91
92    /// The remote daemon, when configured. Serve it over TLS with
93    /// [`remote::serve`](crate::remote::serve) or [`remote::serve_blocking`](crate::remote::serve_blocking).
94    #[cfg(feature = "remote")]
95    pub fn daemon(&self) -> Option<&Arc<Daemon>> {
96        self.daemon.as_ref()
97    }
98
99    /// Release the local host. The remote listener stops with its own `StopHandle`.
100    pub fn shutdown(&self) {
101        if let Some(h) = &self.hosted {
102            h.shutdown();
103        }
104    }
105}
106
107#[derive(Default)]
108pub struct ServiceBuilder {
109    table: Option<MethodTable>,
110    handler: Option<Arc<dyn Handler>>,
111    local: Option<LocalSpec>,
112    #[cfg(feature = "remote")]
113    remote: Option<RemoteSpec>,
114}
115
116impl ServiceBuilder {
117    /// The one source of method policy for every transport.
118    pub fn methods(mut self, table: MethodTable) -> Self {
119        self.table = Some(table);
120        self
121    }
122
123    /// The one handler both transports call.
124    pub fn handler(mut self, handler: Arc<dyn Handler>) -> Self {
125        self.handler = Some(handler);
126        self
127    }
128
129    /// Host on the local transport. The allowlist in `cfg` is replaced by the one the method
130    /// table implies.
131    pub fn local(mut self, suite: &Suite, cfg: ServiceConfig, opts: HostOptions) -> Self {
132        self.local = Some(LocalSpec {
133            suite: suite.clone(),
134            cfg,
135            opts,
136        });
137        self
138    }
139
140    /// Open the remote daemon with the system clock and no OAuth handler.
141    #[cfg(feature = "remote")]
142    pub fn remote(self, cfg: DaemonConfig) -> Self {
143        self.remote_with(cfg, Arc::new(SystemClock), None)
144    }
145
146    /// [`remote`](Self::remote) with an injected clock and an OAuth callback handler.
147    #[cfg(feature = "remote")]
148    pub fn remote_with(
149        mut self,
150        cfg: DaemonConfig,
151        clock: Arc<dyn Clock>,
152        oauth: Option<Arc<dyn OAuthCallbackHandler>>,
153    ) -> Self {
154        self.remote = Some(RemoteSpec { cfg, clock, oauth });
155        self
156    }
157
158    /// Open the configured transports. The remote state opens first (it fails without side
159    /// effects); the local host publishes `service.json` last.
160    pub fn start(self) -> Result<Service, BuildError> {
161        let table = self.table.ok_or(BuildError::Missing("methods"))?;
162        let handler = self.handler.ok_or(BuildError::Missing("handler"))?;
163        #[cfg(feature = "remote")]
164        let has_remote = self.remote.is_some();
165        #[cfg(not(feature = "remote"))]
166        let has_remote = false;
167        if self.local.is_none() && !has_remote {
168            return Err(BuildError::NoTransport);
169        }
170
171        #[cfg(feature = "remote")]
172        let daemon = match self.remote {
173            Some(r) => {
174                let d = Daemon::open(r.cfg, r.clock, r.oauth)
175                    .map_err(BuildError::Io)?
176                    .with_calls(DeviceCalls::new(table.clone(), handler.clone()));
177                Some(Arc::new(d))
178            }
179            None => None,
180        };
181
182        let hosted = match self.local {
183            Some(l) => {
184                let cfg = l.cfg.with_methods(&table);
185                Some(
186                    host_with_options(&l.suite, cfg, handler, l.opts)
187                        .map_err(|e| BuildError::Host(Box::new(e)))?,
188                )
189            }
190            None => None,
191        };
192        Ok(Service {
193            table,
194            hosted,
195            #[cfg(feature = "remote")]
196            daemon,
197        })
198    }
199}