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}