solti-api 0.0.4

HTTP/JSON and gRPC API layer for the Solti task supervisor SDK.
Documentation
//! # Handler Boundary
//!
//! [`ApiHandler`] is the shared backend for HTTP and gRPC.
//! It receives validated [`solti_model`] values.
//! It returns domain values or [`ApiError`].
//!
//! ```text
//! HTTP handlers ──┐
//!                 ├──► ApiHandler ──► backend
//! gRPC service ───┘
//! ```
//!
//! Wire encoding stays outside the handler.
//! A custom implementation can use another store or wrap another backend.

use std::pin::Pin;

use async_trait::async_trait;
use solti_model::{
    OutputEvent, Task, TaskFilter, TaskId, TaskManifest, TaskPage, TaskQuery, TaskRun,
    TaskWatchEvent, WritePreconditions,
};
use tokio_stream::Stream;

use crate::error::ApiError;

/// Boxed live stream of task output events.
///
/// The stream item is [`OutputEvent`].
/// Transport adapters encode each item for their wire format.
pub type OutputEventStream = Pin<Box<dyn Stream<Item = OutputEvent> + Send + 'static>>;

/// Boxed stream of task resource changes.
///
/// A stream item can contain a terminal [`ApiError`].
pub type TaskWatchEventStream =
    Pin<Box<dyn Stream<Item = Result<TaskWatchEvent, ApiError>> + Send + 'static>>;

/// Transport-independent task API.
///
/// The trait covers desired writes, current reads, collection watches,
/// run history, deletion, and live output.
///
/// Implementations must not expose the built-in `Embedded` workload.
/// Both transports check that boundary before encoding a response.
///
/// ## Operations
///
/// | Method             | HTTP                                         | gRPC             |
/// |--------------------|----------------------------------------------|------------------|
/// | `create_task`      | `POST   /apis/solti.io/v1/tasks`             | `CreateTask`     |
/// | `apply_task`       | `PUT    /apis/solti.io/v1/tasks/{name}`      | `ApplyTask`      |
/// | `get_task`         | `GET    /apis/solti.io/v1/tasks/{name}`      | `GetTask`        |
/// | `query_tasks`      | `GET    /apis/solti.io/v1/tasks`             | `ListTasks`      |
/// | `watch_tasks`      | `GET    /apis/solti.io/v1/tasks?watch=true`  | `WatchTasks`     |
/// | `list_task_runs`   | `GET    /apis/solti.io/v1/tasks/{name}/runs` | `ListTaskRuns`   |
/// | `delete_task`      | `DELETE /apis/solti.io/v1/tasks/{name}`      | `DeleteTask`     |
/// | `stream_task_logs` | `GET    /apis/solti.io/v1/tasks/{name}/logs` | `StreamTaskLogs` |
///
/// ## See Also
///
/// - `SupervisorApiAdapter` implements this trait for `solti-core`.
/// - [`ApiError`] defines the shared transport error categories.
#[async_trait]
pub trait ApiHandler: Send + Sync + 'static {
    /// Creates one named task resource.
    ///
    /// The bundled adapter returns committed desired state immediately.
    /// Reconciliation continues in the background.
    /// Its result appears in `status.conditions[type=Reconciled]`.
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns:
    ///
    /// - [`ApiError::InvalidRequest`] when the manifest is rejected.
    /// - [`ApiError::AlreadyExists`] when the name is retained.
    /// - [`ApiError::Unavailable`] after shutdown starts.
    ///
    /// Later reconciliation failures are status updates.
    /// They are not create errors.
    async fn create_task(&self, manifest: TaskManifest) -> Result<Task, ApiError>;

    /// Creates or updates the task addressed by `metadata.name`.
    ///
    /// Empty preconditions make this an upsert.
    /// Any precondition requires an existing matching resource.
    ///
    /// ## Errors
    ///
    /// The bundled adapter can return the errors from
    /// [`create_task`](Self::create_task).
    /// It can also return:
    ///
    /// - [`ApiError::TaskNotFound`] when conditional apply finds no task.
    /// - [`ApiError::Conflict`] when a precondition does not match.
    async fn apply_task(
        &self,
        manifest: TaskManifest,
        preconditions: WritePreconditions,
    ) -> Result<Task, ApiError>;

    /// Returns the current task resource with this name.
    ///
    /// `None` means that no public task has this name.
    ///
    /// ## Errors
    ///
    /// The bundled adapter does not return an error.
    /// A custom implementation can return any [`ApiError`].
    async fn get_task(&self, name: &TaskId) -> Result<Option<Task>, ApiError>;

    /// Returns one filtered task page.
    ///
    /// The returned page must match the query filters and limit.
    /// Its continuation must describe the same snapshot and filter.
    /// The transports reject an inconsistent page as [`ApiError::Internal`].
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns:
    ///
    /// - [`ApiError::InvalidRequest`] for an invalid continuation.
    /// - [`ApiError::ResourceVersionExpired`] for a compacted snapshot.
    async fn query_tasks(&self, query: TaskQuery) -> Result<TaskPage<Task>, ApiError>;

    /// Watches changes to tasks that match the filter.
    ///
    /// With the bundled adapter, an absent resource version or `"0"` first
    /// emits current matches as `Added`.
    /// A specific version replays newer retained changes.
    /// Both forms then continue with live changes.
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns [`ApiError::ResourceVersionExpired`]
    /// when the requested position is no longer retained.
    ///
    /// The stream can later yield the same error when it falls behind.
    /// That error is terminal.
    async fn watch_tasks(
        &self,
        filter: TaskFilter,
        resource_version: Option<String>,
    ) -> Result<TaskWatchEventStream, ApiError>;

    /// Lists one task's execution attempts from oldest to newest.
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns [`ApiError::TaskNotFound`]
    /// when the task is not public or does not exist.
    async fn list_task_runs(&self, id: &TaskId) -> Result<Vec<TaskRun>, ApiError>;

    /// Stops and removes one task and its run history.
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns:
    ///
    /// - [`ApiError::TaskNotFound`] when the task is not public or does not exist.
    /// - [`ApiError::Conflict`] when a precondition does not match.
    /// - [`ApiError::Internal`] when runtime cancellation fails.
    async fn delete_task(
        &self,
        id: &TaskId,
        preconditions: WritePreconditions,
    ) -> Result<(), ApiError>;

    /// Subscribes to one task's live output.
    ///
    /// The stream is lossy and has no replay.
    /// It can cover later attempts of the same task generation.
    /// Run boundary events are best-effort observations.
    /// They are not ordering barriers for output chunks.
    ///
    /// The bundled adapter pins the stream to the generation visible
    /// when this method is called.
    ///
    /// ## Errors
    ///
    /// The bundled adapter returns [`ApiError::TaskNotFound`]
    /// when no public live output channel exists for this task.
    async fn stream_task_logs(&self, id: &TaskId) -> Result<OutputEventStream, ApiError>;
}