Skip to main content

solti_api/
handler.rs

1//! # Handler Boundary
2//!
3//! [`ApiHandler`] is the shared backend for HTTP and gRPC.
4//! It receives validated [`solti_model`] values.
5//! It returns domain values or [`ApiError`].
6//!
7//! ```text
8//! HTTP handlers ──┐
9//!                 ├──► ApiHandler ──► backend
10//! gRPC service ───┘
11//! ```
12//!
13//! Wire encoding stays outside the handler.
14//! A custom implementation can use another store or wrap another backend.
15
16use std::pin::Pin;
17
18use async_trait::async_trait;
19use solti_model::{
20    OutputEvent, Task, TaskFilter, TaskId, TaskManifest, TaskPage, TaskQuery, TaskRun,
21    TaskWatchEvent, WritePreconditions,
22};
23use tokio_stream::Stream;
24
25use crate::error::ApiError;
26
27/// Boxed live stream of task output events.
28///
29/// The stream item is [`OutputEvent`].
30/// Transport adapters encode each item for their wire format.
31pub type OutputEventStream = Pin<Box<dyn Stream<Item = OutputEvent> + Send + 'static>>;
32
33/// Boxed stream of task resource changes.
34///
35/// A stream item can contain a terminal [`ApiError`].
36pub type TaskWatchEventStream =
37    Pin<Box<dyn Stream<Item = Result<TaskWatchEvent, ApiError>> + Send + 'static>>;
38
39/// Transport-independent task API.
40///
41/// The trait covers desired writes, current reads, collection watches,
42/// run history, deletion, and live output.
43///
44/// Implementations must not expose the built-in `Embedded` workload.
45/// Both transports check that boundary before encoding a response.
46///
47/// ## Operations
48///
49/// | Method             | HTTP                                         | gRPC             |
50/// |--------------------|----------------------------------------------|------------------|
51/// | `create_task`      | `POST   /apis/solti.io/v1/tasks`             | `CreateTask`     |
52/// | `apply_task`       | `PUT    /apis/solti.io/v1/tasks/{name}`      | `ApplyTask`      |
53/// | `get_task`         | `GET    /apis/solti.io/v1/tasks/{name}`      | `GetTask`        |
54/// | `query_tasks`      | `GET    /apis/solti.io/v1/tasks`             | `ListTasks`      |
55/// | `watch_tasks`      | `GET    /apis/solti.io/v1/tasks?watch=true`  | `WatchTasks`     |
56/// | `list_task_runs`   | `GET    /apis/solti.io/v1/tasks/{name}/runs` | `ListTaskRuns`   |
57/// | `delete_task`      | `DELETE /apis/solti.io/v1/tasks/{name}`      | `DeleteTask`     |
58/// | `stream_task_logs` | `GET    /apis/solti.io/v1/tasks/{name}/logs` | `StreamTaskLogs` |
59///
60/// ## See Also
61///
62/// - `SupervisorApiAdapter` implements this trait for `solti-core`.
63/// - [`ApiError`] defines the shared transport error categories.
64#[async_trait]
65pub trait ApiHandler: Send + Sync + 'static {
66    /// Creates one named task resource.
67    ///
68    /// The bundled adapter returns committed desired state immediately.
69    /// Reconciliation continues in the background.
70    /// Its result appears in `status.conditions[type=Reconciled]`.
71    ///
72    /// ## Errors
73    ///
74    /// The bundled adapter returns:
75    ///
76    /// - [`ApiError::InvalidRequest`] when the manifest is rejected.
77    /// - [`ApiError::AlreadyExists`] when the name is retained.
78    /// - [`ApiError::Unavailable`] after shutdown starts.
79    ///
80    /// Later reconciliation failures are status updates.
81    /// They are not create errors.
82    async fn create_task(&self, manifest: TaskManifest) -> Result<Task, ApiError>;
83
84    /// Creates or updates the task addressed by `metadata.name`.
85    ///
86    /// Empty preconditions make this an upsert.
87    /// Any precondition requires an existing matching resource.
88    ///
89    /// ## Errors
90    ///
91    /// The bundled adapter can return the errors from
92    /// [`create_task`](Self::create_task).
93    /// It can also return:
94    ///
95    /// - [`ApiError::TaskNotFound`] when conditional apply finds no task.
96    /// - [`ApiError::Conflict`] when a precondition does not match.
97    async fn apply_task(
98        &self,
99        manifest: TaskManifest,
100        preconditions: WritePreconditions,
101    ) -> Result<Task, ApiError>;
102
103    /// Returns the current task resource with this name.
104    ///
105    /// `None` means that no public task has this name.
106    ///
107    /// ## Errors
108    ///
109    /// The bundled adapter does not return an error.
110    /// A custom implementation can return any [`ApiError`].
111    async fn get_task(&self, name: &TaskId) -> Result<Option<Task>, ApiError>;
112
113    /// Returns one filtered task page.
114    ///
115    /// The returned page must match the query filters and limit.
116    /// Its continuation must describe the same snapshot and filter.
117    /// The transports reject an inconsistent page as [`ApiError::Internal`].
118    ///
119    /// ## Errors
120    ///
121    /// The bundled adapter returns:
122    ///
123    /// - [`ApiError::InvalidRequest`] for an invalid continuation.
124    /// - [`ApiError::ResourceVersionExpired`] for a compacted snapshot.
125    async fn query_tasks(&self, query: TaskQuery) -> Result<TaskPage<Task>, ApiError>;
126
127    /// Watches changes to tasks that match the filter.
128    ///
129    /// With the bundled adapter, an absent resource version or `"0"` first
130    /// emits current matches as `Added`.
131    /// A specific version replays newer retained changes.
132    /// Both forms then continue with live changes.
133    ///
134    /// ## Errors
135    ///
136    /// The bundled adapter returns [`ApiError::ResourceVersionExpired`]
137    /// when the requested position is no longer retained.
138    ///
139    /// The stream can later yield the same error when it falls behind.
140    /// That error is terminal.
141    async fn watch_tasks(
142        &self,
143        filter: TaskFilter,
144        resource_version: Option<String>,
145    ) -> Result<TaskWatchEventStream, ApiError>;
146
147    /// Lists one task's execution attempts from oldest to newest.
148    ///
149    /// ## Errors
150    ///
151    /// The bundled adapter returns [`ApiError::TaskNotFound`]
152    /// when the task is not public or does not exist.
153    async fn list_task_runs(&self, id: &TaskId) -> Result<Vec<TaskRun>, ApiError>;
154
155    /// Stops and removes one task and its run history.
156    ///
157    /// ## Errors
158    ///
159    /// The bundled adapter returns:
160    ///
161    /// - [`ApiError::TaskNotFound`] when the task is not public or does not exist.
162    /// - [`ApiError::Conflict`] when a precondition does not match.
163    /// - [`ApiError::Internal`] when runtime cancellation fails.
164    async fn delete_task(
165        &self,
166        id: &TaskId,
167        preconditions: WritePreconditions,
168    ) -> Result<(), ApiError>;
169
170    /// Subscribes to one task's live output.
171    ///
172    /// The stream is lossy and has no replay.
173    /// It can cover later attempts of the same task generation.
174    /// Run boundary events are best-effort observations.
175    /// They are not ordering barriers for output chunks.
176    ///
177    /// The bundled adapter pins the stream to the generation visible
178    /// when this method is called.
179    ///
180    /// ## Errors
181    ///
182    /// The bundled adapter returns [`ApiError::TaskNotFound`]
183    /// when no public live output channel exists for this task.
184    async fn stream_task_logs(&self, id: &TaskId) -> Result<OutputEventStream, ApiError>;
185}