Skip to main content

solti_api/
http.rs

1//! # HTTP Transport
2//!
3//! Axum router for the model-owned CRD JSON representation.
4//! Every operation delegates to [`ApiHandler`].
5//!
6//! The current API root is `/apis/solti.io/v1`.
7//!
8//! ## Routes
9//!
10//! | Method   | Path                                          | Operation   |
11//! |----------|-----------------------------------------------|-------------|
12//! | `POST`   | `/apis/solti.io/v1/tasks`                     | Create      |
13//! | `PUT`    | `/apis/solti.io/v1/tasks/{name}`              | Apply       |
14//! | `GET`    | `/apis/solti.io/v1/tasks/{name}`              | Get         |
15//! | `GET`    | `/apis/solti.io/v1/tasks`                     | List        |
16//! | `GET`    | `/apis/solti.io/v1/tasks?watch=true`          | Watch       |
17//! | `GET`    | `/apis/solti.io/v1/tasks/{name}/runs`         | Run history |
18//! | `GET`    | `/apis/solti.io/v1/tasks/{name}/logs`         | Live output |
19//! | `DELETE` | `/apis/solti.io/v1/tasks/{name}`              | Delete      |
20//!
21//! ## Wire Shapes
22//!
23//! Create and apply accept `TaskManifest` JSON.
24//! Resource reads return `Task` JSON.
25//! Lists return a Kubernetes-style `TaskList`.
26//! Errors return a Kubernetes-style `Status`.
27//!
28//! Watches emit newline-delimited JSON documents.
29//! Live output uses Server-Sent Events.
30//! Both streams remain open until their source ends or fails.
31//!
32//! Every request body is limited to [`crate::MAX_REQUEST_BYTES`].
33
34use std::{
35    convert::Infallible,
36    num::NonZeroU32,
37    pin::Pin,
38    sync::Arc,
39    task::{Context, Poll},
40};
41
42use aide::{
43    NoApi,
44    axum::{
45        ApiRouter,
46        routing::{get_with, post_with},
47    },
48    generate::GenContext,
49    openapi::{
50        Info, MediaType, OpenApi, ReferenceOr, Response as ApiResponse, SchemaObject,
51        SecurityScheme, Tag,
52    },
53    operation::{OperationInput, OperationOutput},
54    transform::TransformOperation,
55};
56use axum::{
57    Json, Router,
58    body::Body,
59    extract::{
60        DefaultBodyLimit, FromRequest, FromRequestParts, Path, Query, RawQuery, Request, State,
61        rejection::{JsonRejection, PathRejection, QueryRejection},
62    },
63    handler::HandlerWithoutStateExt,
64    http::{HeaderValue, StatusCode, header, request::Parts},
65    middleware::{self, Next},
66    response::{
67        IntoResponse, NoContent, Response,
68        sse::{Event, KeepAlive, Sse},
69    },
70};
71use schemars::{JsonSchema, Schema, generate::SchemaSettings};
72use serde::{Deserialize, Serialize, de::DeserializeOwned};
73use solti_model::{
74    AdmissionPolicy, BackoffPolicy, ContainerSpec, ExtensionWorkload, LabelSelector, ObjectMeta,
75    OutputEvent, RestartPolicy, Slot, SubprocessSpec, TASK_API_VERSION, Task, TaskFilter, TaskId,
76    TaskManifest, TaskManifestMeta, TaskPhase, TaskQuery, TaskRun, TaskStatus, TaskWatchEvent,
77    Timeout, Token, TypeMeta, Uid, WORKLOAD_API_VERSION, WasmSpec, WritePreconditions,
78};
79use tokio_stream::{Stream, StreamExt};
80use tower_http::limit::RequestBodyLimitLayer;
81use tracing::debug;
82
83use crate::{
84    API_VERSION, API_VERSION_NAME, GRPC_API_PACKAGE, HTTP_API_ROOT, MAX_REQUEST_BYTES,
85    auth::bearer_value,
86    error::{ApiError, HttpStatusResource},
87    handler::{ApiHandler, TaskWatchEventStream},
88    metrics::{ApiMetricsHandle, StreamingResponse, http_metrics_middleware, noop_api_metrics},
89    validate::{parse_list_limit, parse_task_id, validate_slot},
90    visibility::{manifest_is_visible, run_is_visible, task_is_visible},
91};
92
93const HTTP_BEARER_SCHEME: &str = "soltiTaskBearer";
94
95/// Task manifest extractor with the public HTTP schema.
96struct TaskManifestJson(TaskManifest);
97
98impl<S> FromRequest<S> for TaskManifestJson
99where
100    S: Send + Sync,
101{
102    type Rejection = ApiError;
103
104    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
105        let Json(value) = axum::Json::<TaskManifest>::from_request(req, state)
106            .await
107            .map_err(map_json_rejection)?;
108        Ok(Self(value))
109    }
110}
111
112impl OperationInput for TaskManifestJson {
113    fn operation_input(context: &mut GenContext, operation: &mut aide::openapi::Operation) {
114        <Json<HttpTaskManifestSchema> as OperationInput>::operation_input(context, operation);
115    }
116}
117
118/// Query extractor that maps axum rejections into [`ApiError`].
119pub(crate) struct ApiQuery<T>(pub T);
120
121impl<T, S> FromRequestParts<S> for ApiQuery<T>
122where
123    T: DeserializeOwned,
124    S: Send + Sync,
125{
126    type Rejection = ApiError;
127
128    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
129        let Query(value) = Query::<T>::from_request_parts(parts, state)
130            .await
131            .map_err(map_query_rejection)?;
132        Ok(ApiQuery(value))
133    }
134}
135
136impl<T> OperationInput for ApiQuery<T>
137where
138    T: JsonSchema,
139{
140    fn operation_input(context: &mut GenContext, operation: &mut aide::openapi::Operation) {
141        <Query<T> as OperationInput>::operation_input(context, operation);
142    }
143}
144
145/// Path extractor that maps axum rejections into [`ApiError`].
146pub(crate) struct ApiPath<T>(pub T);
147
148impl<T, S> FromRequestParts<S> for ApiPath<T>
149where
150    T: DeserializeOwned + Send,
151    S: Send + Sync,
152{
153    type Rejection = ApiError;
154
155    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
156        let Path(value) = Path::<T>::from_request_parts(parts, state)
157            .await
158            .map_err(map_path_rejection)?;
159        Ok(ApiPath(value))
160    }
161}
162
163impl<T> OperationInput for ApiPath<T>
164where
165    T: JsonSchema,
166{
167    fn operation_input(context: &mut GenContext, operation: &mut aide::openapi::Operation) {
168        <Path<T> as OperationInput>::operation_input(context, operation);
169    }
170}
171
172fn map_path_rejection(rejection: PathRejection) -> ApiError {
173    ApiError::InvalidRequest(rejection.body_text())
174}
175
176fn map_query_rejection(rejection: QueryRejection) -> ApiError {
177    ApiError::InvalidRequest(rejection.body_text())
178}
179
180fn map_json_rejection(rej: JsonRejection) -> ApiError {
181    if rej.status() == StatusCode::PAYLOAD_TOO_LARGE {
182        return ApiError::PayloadTooLarge(format!(
183            "request body exceeds the maximum of {} bytes",
184            MAX_REQUEST_BYTES
185        ));
186    }
187    if rej.status() == StatusCode::UNSUPPORTED_MEDIA_TYPE {
188        return ApiError::UnsupportedMediaType(rej.body_text());
189    }
190
191    let msg = rej.body_text();
192    let trimmed = msg
193        .strip_prefix("Failed to deserialize the JSON body into the target type: ")
194        .or_else(|| msg.strip_prefix("Failed to parse the request body as JSON: "))
195        .unwrap_or(&msg)
196        .to_string();
197    ApiError::InvalidRequest(trimmed)
198}
199
200async fn map_413_envelope(req: Request, next: Next) -> Response {
201    let resp = next.run(req).await;
202    if resp.status() == StatusCode::PAYLOAD_TOO_LARGE {
203        return ApiError::PayloadTooLarge(format!(
204            "request body exceeds the maximum of {} bytes",
205            MAX_REQUEST_BYTES
206        ))
207        .into_response();
208    }
209    resp
210}
211
212async fn route_not_found(req: Request) -> Response {
213    ApiError::NotFound(format!("no API route for `{}`", req.uri().path())).into_response()
214}
215
216async fn method_not_allowed(req: Request) -> Response {
217    ApiError::MethodNotAllowed(format!(
218        "method {} is not allowed for `{}`",
219        req.method(),
220        req.uri().path()
221    ))
222    .into_response()
223}
224
225/// Standalone runtime router and its generated OpenAPI contract.
226///
227/// The document describes the exact routes installed on [`router`](Self::router).
228/// It is generated in memory and is not written to the source tree.
229pub struct HttpApiParts {
230    /// Configured task API router.
231    pub router: Router,
232
233    /// OpenAPI 3.1 document for the configured router.
234    pub openapi: OpenApi,
235}
236
237/// Builder for the axum task API.
238///
239/// Authentication and metrics are optional.
240/// [`build`](Self::build) returns a standalone router and OpenAPI document.
241/// [`mount`](Self::mount) adds the documented task subtree to an application router.
242/// [`router`](Self::router) returns only the router.
243///
244/// ## Example
245///
246/// ```rust,no_run
247/// use std::sync::Arc;
248///
249/// use solti_api::{ApiHandler, HttpApi};
250/// use solti_model::Token;
251///
252/// fn build<H: ApiHandler>(
253///     handler: Arc<H>,
254///     token: Token,
255/// ) -> solti_api::axum::Router {
256///     HttpApi::new(handler)
257///         .with_auth(token)
258///         .router()
259/// }
260/// ```
261///
262/// ## See Also
263///
264/// - [`ApiHandler`] defines the backend operations.
265/// - [`ApiError`] defines the HTTP `Status` mapping.
266pub struct HttpApi<H> {
267    handler: Arc<H>,
268    metrics: ApiMetricsHandle,
269    auth: Option<Token>,
270}
271
272impl<H> HttpApi<H>
273where
274    H: ApiHandler,
275{
276    /// Creates an HTTP API for one handler.
277    pub fn new(handler: Arc<H>) -> Self {
278        Self {
279            handler,
280            metrics: noop_api_metrics(),
281            auth: None,
282        }
283    }
284
285    /// Requires a bearer token on every request.
286    ///
287    /// The expected header is `Authorization: Bearer <token>`.
288    /// Missing or invalid credentials return `401 Unauthorized`.
289    /// Rejected requests do not reach the handler.
290    /// Authentication is disabled when this method is not called.
291    pub fn with_auth(mut self, token: Token) -> Self {
292        self.auth = Some(token);
293        self
294    }
295
296    /// Attaches a metrics backend.
297    ///
298    /// The default backend ignores every update.
299    pub fn with_metrics(mut self, metrics: ApiMetricsHandle) -> Self {
300        self.metrics = metrics;
301        self
302    }
303
304    /// Builds a standalone router and its OpenAPI document.
305    ///
306    /// Every request body is limited to [`MAX_REQUEST_BYTES`].
307    /// Optional authentication runs before the handler.
308    /// Metrics include unmatched task API routes and authentication failures.
309    pub fn build(self) -> HttpApiParts {
310        configure_standalone_openapi_generation();
311
312        let mut openapi = standalone_openapi_document();
313        let router = self
314            .mount(ApiRouter::new(), &mut openapi)
315            .fallback(route_not_found)
316            .finish_api(&mut openapi);
317
318        HttpApiParts { router, openapi }
319    }
320
321    /// Mounts the documented task API into an application router.
322    ///
323    /// The task routes are mounted at [`HTTP_API_ROOT`].
324    /// Their handler state, authentication, limits, metrics, and fallbacks stay inside that route subtree.
325    ///
326    /// The caller owns the final [`OpenApi`] document.
327    /// Call [`ApiRouter::finish_api`] once after every documented service has been mounted.
328    pub fn mount<S>(self, app: ApiRouter<S>, openapi: &mut OpenApi) -> ApiRouter<S>
329    where
330        S: Clone + Send + Sync + 'static,
331    {
332        let auth_enabled = self.auth.is_some();
333        document_task_api(openapi, auth_enabled);
334
335        let mut router = documented_router::<H>(auth_enabled)
336            .fallback(route_not_found)
337            .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES))
338            .layer(RequestBodyLimitLayer::new(MAX_REQUEST_BYTES))
339            .layer(middleware::from_fn(map_413_envelope));
340
341        if let Some(token) = self.auth {
342            router = router.layer(middleware::from_fn_with_state(token, require_bearer));
343        }
344
345        router = router.layer(middleware::from_fn_with_state(
346            self.metrics,
347            http_metrics_middleware,
348        ));
349
350        app.nest_api_service(HTTP_API_ROOT, router.with_state(self.handler))
351    }
352
353    /// Builds only the configured axum router.
354    ///
355    /// Use [`build`](Self::build) when the OpenAPI document is needed.
356    pub fn router(self) -> Router {
357        self.build().router
358    }
359}
360
361fn configure_standalone_openapi_generation() {
362    aide::generate::reset_context();
363    aide::generate::in_context(|context| {
364        let settings = SchemaSettings::draft2020_12().with(|settings| {
365            settings.inline_subschemas = false;
366            settings.definitions_path = "#/components/schemas/".into();
367            settings.meta_schema = None;
368        });
369        context.schema = settings.into_generator();
370    });
371}
372
373fn documented_router<H>(auth_enabled: bool) -> ApiRouter<Arc<H>>
374where
375    H: ApiHandler,
376{
377    let tasks = post_with(create_task_route::<H>, move |operation| {
378        let operation = operation
379            .id("createTask")
380            .tag("tasks")
381            .summary("Create a task")
382            .description(
383                "Commits new desired state. See CONTRACT.md for reconciliation semantics.",
384            )
385            .response::<201, Json<HttpTaskSchema>>()
386            .response::<400, ApiError>()
387            .response::<409, ApiError>()
388            .response::<413, ApiError>()
389            .response::<415, ApiError>();
390        document_common_errors(document_auth(operation, auth_enabled))
391    })
392    .get_with(list_tasks_route::<H>, move |operation| {
393        let operation = operation
394            .id("listOrWatchTasks")
395            .tag("tasks")
396            .summary("List or watch tasks")
397            .description(
398                "Returns one TaskList unless watch is true. Watch mode emits newline-delimited TaskWatchDocument values. See CONTRACT.md for pagination and resume semantics.",
399            )
400            .input::<Query<ListTasksParams>>()
401            .response::<200, ListOrWatchResponse>()
402            .response::<400, ApiError>()
403            .response::<410, ApiError>();
404        document_common_errors(document_auth(operation, auth_enabled))
405    })
406    .fallback_service(method_not_allowed.into_service());
407
408    let task = get_with(get_task_route::<H>, move |operation| {
409        let operation = operation
410            .id("getTask")
411            .tag("tasks")
412            .summary("Get a task")
413            .response::<200, Json<HttpTaskSchema>>()
414            .response::<400, ApiError>()
415            .response::<404, ApiError>();
416        document_common_errors(document_auth(operation, auth_enabled))
417    })
418    .put_with(apply_task_route::<H>, move |operation| {
419        let operation = operation
420            .id("applyTask")
421            .tag("tasks")
422            .summary("Apply desired task state")
423            .description(
424                "Creates or updates one task. See CONTRACT.md for preconditions and commit semantics.",
425            )
426            .response::<200, Json<HttpTaskSchema>>()
427            .response::<400, ApiError>()
428            .response::<404, ApiError>()
429            .response::<409, ApiError>()
430            .response::<413, ApiError>()
431            .response::<415, ApiError>();
432        document_common_errors(document_auth(operation, auth_enabled))
433    })
434    .delete_with(delete_task_route::<H>, move |operation| {
435        let operation = operation
436            .id("deleteTask")
437            .tag("tasks")
438            .summary("Delete a task")
439            .description("Stops the task and removes its retained history.")
440            .response::<204, NoContent>()
441            .response::<400, ApiError>()
442            .response::<404, ApiError>()
443            .response::<409, ApiError>();
444        document_common_errors(document_auth(operation, auth_enabled))
445    })
446    .fallback_service(method_not_allowed.into_service());
447
448    let runs = get_with(list_task_runs_route::<H>, move |operation| {
449        let operation = operation
450            .id("listTaskRuns")
451            .tag("tasks")
452            .summary("List retained task runs")
453            .response::<200, Json<TaskRunList>>()
454            .response::<400, ApiError>()
455            .response::<404, ApiError>();
456        document_common_errors(document_auth(operation, auth_enabled))
457    })
458    .fallback_service(method_not_allowed.into_service());
459
460    let logs = get_with(stream_task_logs_route::<H>, move |operation| {
461        let operation = operation
462            .id("streamTaskLogs")
463            .tag("tasks")
464            .summary("Stream live task output")
465            .description(
466                "Returns Server-Sent Events. See CONTRACT.md for event names, payloads, and delivery semantics.",
467            )
468            .response::<200, TaskLogStreamResponse>()
469            .response::<400, ApiError>()
470            .response::<404, ApiError>();
471        document_common_errors(document_auth(operation, auth_enabled))
472    })
473    .fallback_service(method_not_allowed.into_service());
474
475    ApiRouter::new()
476        .api_route("/tasks", tasks)
477        .api_route("/tasks/{name}", task)
478        .api_route("/tasks/{name}/runs", runs)
479        .api_route("/tasks/{name}/logs", logs)
480}
481
482fn document_auth<'a>(
483    operation: TransformOperation<'a>,
484    auth_enabled: bool,
485) -> TransformOperation<'a> {
486    if auth_enabled {
487        operation
488            .security_requirement(HTTP_BEARER_SCHEME)
489            .response::<401, ApiError>()
490    } else {
491        operation.security_requirement_multi(std::iter::empty::<&str>())
492    }
493}
494
495fn document_common_errors(operation: TransformOperation<'_>) -> TransformOperation<'_> {
496    operation
497        .response::<500, ApiError>()
498        .response::<503, ApiError>()
499}
500
501fn standalone_openapi_document() -> OpenApi {
502    OpenApi {
503        info: Info {
504            title: "Solti Task API".into(),
505            summary: Some("Public task transport exposed by one Solti agent.".into()),
506            description: Some(
507                "OpenAPI describes HTTP shapes and operations. CONTRACT.md defines behavioral semantics."
508                    .into(),
509            ),
510            version: API_VERSION_NAME.into(),
511            ..Info::default()
512        },
513        json_schema_dialect: Some("https://json-schema.org/draft/2020-12/schema".into()),
514        ..OpenApi::default()
515    }
516}
517
518fn document_task_api(openapi: &mut OpenApi, auth_enabled: bool) {
519    openapi
520        .extensions
521        .insert("x-solti-task-api-version".into(), API_VERSION.into());
522    openapi.extensions.insert(
523        "x-solti-resource-api-version".into(),
524        TASK_API_VERSION.into(),
525    );
526    openapi
527        .extensions
528        .insert("x-solti-http-api-root".into(), HTTP_API_ROOT.into());
529    openapi
530        .extensions
531        .insert("x-solti-grpc-package".into(), GRPC_API_PACKAGE.into());
532
533    if !openapi.tags.iter().any(|tag| tag.name == "tasks") {
534        openapi.tags.push(Tag {
535            name: "tasks".into(),
536            description: Some("Desired task state, observations, history, and live output.".into()),
537            ..Tag::default()
538        });
539    }
540
541    if auth_enabled {
542        let components = openapi.components.get_or_insert_default();
543        components.security_schemes.insert(
544            HTTP_BEARER_SCHEME.into(),
545            ReferenceOr::Item(SecurityScheme::Http {
546                scheme: "bearer".into(),
547                bearer_format: None,
548                description: Some("Token configured by HttpApi::with_auth.".into()),
549                extensions: Default::default(),
550            }),
551        );
552    }
553}
554
555/// Enforces the configured bearer token.
556async fn require_bearer(State(expected): State<Token>, req: Request, next: Next) -> Response {
557    let ok = req
558        .headers()
559        .get(axum::http::header::AUTHORIZATION)
560        .and_then(|v| v.to_str().ok())
561        .and_then(bearer_value)
562        .map(|presented| expected.verify(presented))
563        .unwrap_or(false);
564
565    if ok {
566        next.run(req).await
567    } else {
568        ApiError::Unauthenticated("missing or invalid bearer token".into()).into_response()
569    }
570}
571
572#[derive(Debug, Default, JsonSchema)]
573#[schemars(rename = "ListTasksQuery", deny_unknown_fields)]
574struct ListTasksParams {
575    /// Match one slot.
576    #[schemars(with = "Option<Slot>")]
577    slot: Option<String>,
578
579    /// Match any supplied phase.
580    #[schemars(rename = "phase", with = "Option<Vec<TaskPhase>>")]
581    phases: Vec<String>,
582
583    /// Match a Kubernetes-style label selector.
584    #[schemars(rename = "labelSelector")]
585    label_selector: Option<String>,
586
587    /// Page size.
588    ///
589    /// Omitted or zero means 100.
590    /// The maximum is 1000.
591    #[schemars(range(max = 1000))]
592    limit: Option<u32>,
593
594    /// Opaque continuation token returned by a previous page.
595    #[schemars(rename = "continue", with = "Option<NonEmptyStringSchema>")]
596    continuation: Option<String>,
597
598    /// Opaque watch start position.
599    #[schemars(rename = "resourceVersion", with = "Option<NonEmptyStringSchema>")]
600    resource_version: Option<String>,
601
602    /// Select watch mode.
603    #[schemars(with = "Option<WatchQuerySchema>")]
604    watch: Option<bool>,
605}
606
607fn parse_list_tasks_params(raw_query: Option<&str>) -> Result<ListTasksParams, ApiError> {
608    let mut params = ListTasksParams::default();
609    for (key, value) in form_urlencoded::parse(raw_query.unwrap_or_default().as_bytes()) {
610        match key.as_ref() {
611            "slot" => set_query_param(&mut params.slot, "slot", value.into_owned())?,
612            "phase" => params.phases.push(value.into_owned()),
613            "labelSelector" => set_query_param(
614                &mut params.label_selector,
615                "labelSelector",
616                value.into_owned(),
617            )?,
618            "limit" => {
619                let value = parse_u32_query_param("limit", &value)?;
620                set_query_param(&mut params.limit, "limit", value)?;
621            }
622            "continue" => {
623                set_query_param(&mut params.continuation, "continue", value.into_owned())?
624            }
625            "resourceVersion" => set_query_param(
626                &mut params.resource_version,
627                "resourceVersion",
628                value.into_owned(),
629            )?,
630            "watch" => {
631                let value = parse_watch_query_param(&value)?;
632                set_query_param(&mut params.watch, "watch", value)?;
633            }
634            other => {
635                return Err(ApiError::InvalidRequest(format!(
636                    "unknown query parameter `{other}`"
637                )));
638            }
639        }
640    }
641    Ok(params)
642}
643
644fn parse_watch_query_param(value: &str) -> Result<bool, ApiError> {
645    match value {
646        "true" | "1" => Ok(true),
647        "false" | "0" => Ok(false),
648        _ => Err(ApiError::InvalidRequest(
649            "query parameter `watch` must be one of: true, false, 1, 0".into(),
650        )),
651    }
652}
653
654fn set_query_param<T>(target: &mut Option<T>, name: &str, value: T) -> Result<(), ApiError> {
655    if target.replace(value).is_some() {
656        return Err(ApiError::InvalidRequest(format!(
657            "query parameter `{name}` must not be repeated"
658        )));
659    }
660    Ok(())
661}
662
663fn parse_u32_query_param(name: &str, value: &str) -> Result<u32, ApiError> {
664    value.parse().map_err(|_| {
665        ApiError::InvalidRequest(format!(
666            "query parameter `{name}` must be an unsigned 32-bit integer"
667        ))
668    })
669}
670
671#[derive(Debug, Deserialize, JsonSchema)]
672#[schemars(deny_unknown_fields)]
673#[serde(rename_all = "camelCase", deny_unknown_fields)]
674struct WriteParams {
675    /// Require the current resource UID.
676    #[schemars(with = "Option<Uid>")]
677    uid: Option<String>,
678
679    /// Require the current opaque resource version.
680    #[schemars(with = "Option<NonEmptyStringSchema>")]
681    resource_version: Option<String>,
682}
683
684fn parse_write_preconditions(params: WriteParams) -> Result<WritePreconditions, ApiError> {
685    let mut preconditions = WritePreconditions::new();
686    if let Some(uid) = params.uid {
687        preconditions = preconditions.with_uid(
688            Uid::new(uid)
689                .map_err(|error| ApiError::InvalidRequest(format!("invalid uid: {error}")))?,
690        );
691    }
692    if let Some(resource_version) = params.resource_version {
693        preconditions = preconditions
694            .with_resource_version(resource_version)
695            .map_err(|error| {
696                ApiError::InvalidRequest(format!("invalid resourceVersion: {error}"))
697            })?;
698    }
699    Ok(preconditions)
700}
701
702#[derive(Debug, Deserialize, JsonSchema)]
703#[schemars(deny_unknown_fields)]
704#[serde(deny_unknown_fields)]
705struct TaskPath {
706    /// Stable task name.
707    #[schemars(with = "TaskId")]
708    name: String,
709}
710
711#[allow(dead_code)]
712#[derive(JsonSchema)]
713#[schemars(
714    rename = "SoltiTaskSpec",
715    rename_all = "camelCase",
716    deny_unknown_fields
717)]
718struct HttpTaskSpecSchema {
719    slot: Slot,
720    workload: HttpTaskWorkloadSchema,
721    timeout: Timeout,
722    restart: RestartPolicy,
723    backoff: BackoffPolicy,
724    admission: AdmissionPolicy,
725    max_retries: Option<NonZeroU32>,
726    runner_selector: Option<LabelSelector>,
727}
728
729struct HttpTaskWorkloadSchema;
730
731impl JsonSchema for HttpTaskWorkloadSchema {
732    fn schema_name() -> std::borrow::Cow<'static, str> {
733        "SoltiTaskWorkload".into()
734    }
735
736    fn json_schema(generator: &mut schemars::SchemaGenerator) -> Schema {
737        let subprocess = http_workload_envelope_schema(
738            "Subprocess",
739            generator.subschema_for::<SubprocessSpec>(),
740        );
741        let wasm = http_workload_envelope_schema("Wasm", generator.subschema_for::<WasmSpec>());
742        let container =
743            http_workload_envelope_schema("Container", generator.subschema_for::<ContainerSpec>());
744        let extension = generator.subschema_for::<ExtensionWorkload>();
745
746        schemars::json_schema!({
747            "description": "Public workload GVK and desired state. Embedded is in-process only.",
748            "oneOf": [subprocess, wasm, container, extension]
749        })
750    }
751}
752
753fn http_workload_envelope_schema(kind: &'static str, spec: Schema) -> Schema {
754    schemars::json_schema!({
755        "type": "object",
756        "additionalProperties": false,
757        "required": ["apiVersion", "kind", "spec"],
758        "properties": {
759            "apiVersion": {
760                "type": "string",
761                "const": WORKLOAD_API_VERSION
762            },
763            "kind": {
764                "type": "string",
765                "const": kind
766            },
767            "spec": spec
768        }
769    })
770}
771
772#[allow(dead_code)]
773#[derive(JsonSchema)]
774#[schemars(
775    rename = "SoltiTaskManifest",
776    rename_all = "camelCase",
777    deny_unknown_fields
778)]
779struct HttpTaskManifestSchema {
780    #[schemars(flatten)]
781    type_meta: TypeMeta,
782    metadata: TaskManifestMeta,
783    spec: HttpTaskSpecSchema,
784}
785
786#[allow(dead_code)]
787#[derive(JsonSchema)]
788#[schemars(rename = "SoltiTask", rename_all = "camelCase", deny_unknown_fields)]
789struct HttpTaskSchema {
790    #[schemars(flatten)]
791    type_meta: TypeMeta,
792    metadata: ObjectMeta,
793    spec: HttpTaskSpecSchema,
794    status: TaskStatus,
795}
796
797#[derive(Debug, JsonSchema, Serialize)]
798#[schemars(deny_unknown_fields)]
799#[serde(rename_all = "camelCase")]
800struct ListMeta {
801    /// Collection version shared by every page in one snapshot.
802    resource_version: String,
803
804    /// Opaque continuation token.
805    #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
806    continuation: Option<String>,
807
808    /// Remaining items after this page.
809    #[serde(skip_serializing_if = "Option::is_none")]
810    remaining_item_count: Option<usize>,
811}
812
813#[derive(Debug, Serialize)]
814#[serde(rename_all = "camelCase")]
815struct TaskList {
816    api_version: &'static str,
817
818    kind: &'static str,
819
820    metadata: ListMeta,
821    items: Vec<Task>,
822}
823
824#[allow(dead_code)]
825#[derive(JsonSchema)]
826#[schemars(
827    rename = "SoltiTaskList",
828    rename_all = "camelCase",
829    deny_unknown_fields
830)]
831struct HttpTaskListSchema {
832    #[schemars(schema_with = "task_api_version")]
833    api_version: &'static str,
834
835    #[schemars(schema_with = "task_list_kind")]
836    kind: &'static str,
837
838    metadata: ListMeta,
839    items: Vec<HttpTaskSchema>,
840}
841
842#[derive(Debug, JsonSchema, Serialize)]
843#[schemars(deny_unknown_fields)]
844struct TaskRunList {
845    runs: Vec<TaskRun>,
846}
847
848#[derive(Serialize)]
849#[serde(tag = "type", content = "object")]
850enum TaskWatchDocument {
851    #[serde(rename = "ADDED")]
852    Added(Task),
853    #[serde(rename = "MODIFIED")]
854    Modified(Task),
855    #[serde(rename = "DELETED")]
856    Deleted(Task),
857    #[serde(rename = "ERROR")]
858    Error(HttpStatusResource),
859}
860
861#[allow(dead_code)]
862#[derive(JsonSchema)]
863#[schemars(rename = "SoltiTaskWatchDocument", tag = "type", content = "object")]
864enum HttpTaskWatchDocumentSchema {
865    #[schemars(rename = "ADDED")]
866    Added(HttpTaskSchema),
867    #[schemars(rename = "MODIFIED")]
868    Modified(HttpTaskSchema),
869    #[schemars(rename = "DELETED")]
870    Deleted(HttpTaskSchema),
871    #[schemars(rename = "ERROR")]
872    Error(HttpStatusResource),
873}
874
875struct ListOrWatchResponse;
876
877impl OperationOutput for ListOrWatchResponse {
878    type Inner = serde_json::Value;
879
880    fn operation_response(
881        context: &mut GenContext,
882        _operation: &mut aide::openapi::Operation,
883    ) -> Option<ApiResponse> {
884        let list = context.schema.subschema_for::<HttpTaskListSchema>();
885        let watch_document = context
886            .schema
887            .subschema_for::<HttpTaskWatchDocumentSchema>();
888        Some(ApiResponse {
889            description: "A TaskList, or a newline-delimited sequence of TaskWatchDocument values when watch is true.".into(),
890            content: [(
891                "application/json".into(),
892                media_type(schemars::json_schema!({
893                    "description": "List and watch share one HTTP media type. See CONTRACT.md for stream framing.",
894                    "oneOf": [list, watch_document]
895                })),
896            )]
897            .into_iter()
898            .collect(),
899            ..ApiResponse::default()
900        })
901    }
902}
903
904struct TaskLogStreamResponse;
905
906impl OperationOutput for TaskLogStreamResponse {
907    type Inner = serde_json::Value;
908
909    fn operation_response(
910        context: &mut GenContext,
911        _operation: &mut aide::openapi::Operation,
912    ) -> Option<ApiResponse> {
913        Some(ApiResponse {
914            description:
915                "Server-Sent Events. Each data field is JSON matching OutputEvent. See CONTRACT.md for framing and delivery semantics."
916                    .into(),
917            content: [(
918                "text/event-stream".into(),
919                media_type(context.schema.subschema_for::<OutputEvent>()),
920            )]
921            .into_iter()
922            .collect(),
923            ..ApiResponse::default()
924        })
925    }
926}
927
928impl OperationOutput for ApiError {
929    type Inner = serde_json::Value;
930
931    fn operation_response(
932        context: &mut GenContext,
933        _operation: &mut aide::openapi::Operation,
934    ) -> Option<ApiResponse> {
935        Some(json_response::<HttpStatusResource>(
936            context,
937            "Kubernetes-style Status failure.",
938        ))
939    }
940}
941
942fn json_response<T>(context: &mut GenContext, description: &str) -> ApiResponse
943where
944    T: JsonSchema,
945{
946    ApiResponse {
947        description: description.into(),
948        content: [(
949            "application/json".into(),
950            media_type(context.schema.subschema_for::<T>()),
951        )]
952        .into_iter()
953        .collect(),
954        ..ApiResponse::default()
955    }
956}
957
958fn media_type(schema: Schema) -> MediaType {
959    MediaType {
960        schema: Some(SchemaObject {
961            json_schema: schema,
962            example: None,
963            external_docs: None,
964        }),
965        ..MediaType::default()
966    }
967}
968
969struct WatchQuerySchema;
970
971impl JsonSchema for WatchQuerySchema {
972    fn schema_name() -> std::borrow::Cow<'static, str> {
973        "WatchQuery".into()
974    }
975
976    fn inline_schema() -> bool {
977        true
978    }
979
980    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> Schema {
981        schemars::json_schema!({
982            "oneOf": [
983                {
984                    "type": "boolean"
985                },
986                {
987                    "type": "string",
988                    "enum": ["0", "1"]
989                }
990            ]
991        })
992    }
993}
994
995struct NonEmptyStringSchema;
996
997impl JsonSchema for NonEmptyStringSchema {
998    fn schema_name() -> std::borrow::Cow<'static, str> {
999        "NonEmptyString".into()
1000    }
1001
1002    fn inline_schema() -> bool {
1003        true
1004    }
1005
1006    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> Schema {
1007        schemars::json_schema!({
1008            "type": "string",
1009            "minLength": 1,
1010            "pattern": "\\S"
1011        })
1012    }
1013}
1014
1015fn task_api_version(_generator: &mut schemars::SchemaGenerator) -> Schema {
1016    schemars::json_schema!({
1017        "type": "string",
1018        "const": TASK_API_VERSION
1019    })
1020}
1021
1022fn task_list_kind(_generator: &mut schemars::SchemaGenerator) -> Schema {
1023    schemars::json_schema!({
1024        "type": "string",
1025        "const": "TaskList"
1026    })
1027}
1028
1029struct TaskWatchBodyStream {
1030    source: TaskWatchEventStream,
1031    terminated: bool,
1032}
1033
1034impl TaskWatchBodyStream {
1035    fn new(source: TaskWatchEventStream) -> Self {
1036        Self {
1037            source,
1038            terminated: false,
1039        }
1040    }
1041}
1042
1043impl Stream for TaskWatchBodyStream {
1044    type Item = Result<Vec<u8>, Infallible>;
1045
1046    fn poll_next(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1047        if self.terminated {
1048            return Poll::Ready(None);
1049        }
1050
1051        match self.source.as_mut().poll_next(context) {
1052            Poll::Ready(Some(event)) => {
1053                let event = event.and_then(public_watch_event);
1054                self.terminated = event.is_err();
1055                Poll::Ready(Some(Ok(encode_watch_document(event))))
1056            }
1057            Poll::Ready(None) => {
1058                self.terminated = true;
1059                Poll::Ready(None)
1060            }
1061            Poll::Pending => Poll::Pending,
1062        }
1063    }
1064}
1065
1066fn reject_embedded_manifest(manifest: &TaskManifest) -> Result<(), ApiError> {
1067    if !manifest_is_visible(manifest) {
1068        return Err(ApiError::InvalidRequest(
1069            "Embedded workloads are available only through the in-process SDK".into(),
1070        ));
1071    }
1072    Ok(())
1073}
1074
1075fn public_task(task: Task) -> Result<Task, ApiError> {
1076    if !task_is_visible(&task) {
1077        return Err(ApiError::Internal(
1078            "handler returned an Embedded workload through the public HTTP API".into(),
1079        ));
1080    }
1081    Ok(task)
1082}
1083
1084fn build_task_filter(
1085    slot: Option<String>,
1086    phases: Vec<String>,
1087    label_selector: Option<String>,
1088) -> Result<TaskFilter, ApiError> {
1089    let mut filter = TaskFilter::new();
1090
1091    if let Some(slot) = slot {
1092        filter = filter.with_slot(validate_slot(slot)?);
1093    }
1094
1095    for phase_str in phases {
1096        let phase = phase_str.parse::<TaskPhase>().map_err(|_| {
1097            ApiError::InvalidRequest(format!(
1098                "invalid phase: '{phase_str}' (valid: pending, running, succeeded, failed, timeout, canceled, exhausted)"
1099            ))
1100        })?;
1101        filter = filter.with_phase(phase);
1102    }
1103
1104    if let Some(label_selector) = label_selector {
1105        let selector = label_selector
1106            .parse::<LabelSelector>()
1107            .map_err(|error| ApiError::InvalidRequest(format!("invalid labelSelector: {error}")))?;
1108        filter = filter
1109            .with_label_selector(selector)
1110            .map_err(|error| ApiError::InvalidRequest(error.to_string()))?;
1111    }
1112
1113    Ok(filter)
1114}
1115
1116fn public_watch_event(event: TaskWatchEvent) -> Result<TaskWatchEvent, ApiError> {
1117    if !task_is_visible(event.object()) {
1118        return Err(ApiError::Internal(
1119            "handler returned an Embedded workload through the public HTTP watch".into(),
1120        ));
1121    }
1122    Ok(event)
1123}
1124
1125fn encode_watch_document(event: Result<TaskWatchEvent, ApiError>) -> Vec<u8> {
1126    let document = match event {
1127        Ok(TaskWatchEvent::Added(task)) => TaskWatchDocument::Added(task),
1128        Ok(TaskWatchEvent::Modified(task)) => TaskWatchDocument::Modified(task),
1129        Ok(TaskWatchEvent::Deleted(task)) => TaskWatchDocument::Deleted(task),
1130        Err(error) => {
1131            let (_, status) = error.into_http_status();
1132            TaskWatchDocument::Error(status)
1133        }
1134    };
1135
1136    match serde_json::to_vec(&document) {
1137        Ok(mut bytes) => {
1138            bytes.push(b'\n');
1139            bytes
1140        }
1141        Err(error) => {
1142            tracing::error!(%error, "failed to serialize HTTP task watch event");
1143            br#"{"type":"ERROR","object":{"apiVersion":"v1","kind":"Status","metadata":{},"status":"Failure","message":"internal server error","reason":"InternalError","code":500}}
1144"#
1145            .to_vec()
1146        }
1147    }
1148}
1149
1150async fn create_task_route<H>(
1151    state: State<Arc<H>>,
1152    manifest: TaskManifestJson,
1153) -> NoApi<Result<(StatusCode, Json<Task>), ApiError>>
1154where
1155    H: ApiHandler,
1156{
1157    NoApi(create_task(state, manifest).await)
1158}
1159
1160async fn apply_task_route<H>(
1161    state: State<Arc<H>>,
1162    path: ApiPath<TaskPath>,
1163    query: ApiQuery<WriteParams>,
1164    manifest: TaskManifestJson,
1165) -> NoApi<Result<Json<Task>, ApiError>>
1166where
1167    H: ApiHandler,
1168{
1169    NoApi(apply_task(state, path, query, manifest).await)
1170}
1171
1172async fn get_task_route<H>(
1173    state: State<Arc<H>>,
1174    path: ApiPath<TaskPath>,
1175) -> NoApi<Result<Json<Task>, ApiError>>
1176where
1177    H: ApiHandler,
1178{
1179    NoApi(get_task(state, path).await)
1180}
1181
1182async fn list_tasks_route<H>(
1183    state: State<Arc<H>>,
1184    query: RawQuery,
1185) -> NoApi<Result<Response, ApiError>>
1186where
1187    H: ApiHandler,
1188{
1189    NoApi(list_tasks(state, query).await)
1190}
1191
1192async fn list_task_runs_route<H>(
1193    state: State<Arc<H>>,
1194    path: ApiPath<TaskPath>,
1195) -> NoApi<Result<Json<TaskRunList>, ApiError>>
1196where
1197    H: ApiHandler,
1198{
1199    NoApi(list_task_runs(state, path).await)
1200}
1201
1202async fn delete_task_route<H>(
1203    state: State<Arc<H>>,
1204    path: ApiPath<TaskPath>,
1205    query: ApiQuery<WriteParams>,
1206) -> NoApi<Result<NoContent, ApiError>>
1207where
1208    H: ApiHandler,
1209{
1210    NoApi(delete_task(state, path, query).await)
1211}
1212
1213async fn stream_task_logs_route<H>(
1214    state: State<Arc<H>>,
1215    path: ApiPath<TaskPath>,
1216) -> NoApi<Result<Response, ApiError>>
1217where
1218    H: ApiHandler,
1219{
1220    NoApi(stream_task_logs(state, path).await)
1221}
1222
1223async fn create_task<H>(
1224    State(handler): State<Arc<H>>,
1225    TaskManifestJson(manifest): TaskManifestJson,
1226) -> Result<(StatusCode, Json<Task>), ApiError>
1227where
1228    H: ApiHandler,
1229{
1230    reject_embedded_manifest(&manifest)?;
1231    debug!(name = %manifest.name(), "creating task");
1232    let task = public_task(handler.create_task(manifest).await?)?;
1233    Ok((StatusCode::CREATED, Json(task)))
1234}
1235
1236async fn apply_task<H>(
1237    State(handler): State<Arc<H>>,
1238    ApiPath(TaskPath { name: path_name }): ApiPath<TaskPath>,
1239    ApiQuery(params): ApiQuery<WriteParams>,
1240    TaskManifestJson(manifest): TaskManifestJson,
1241) -> Result<Json<Task>, ApiError>
1242where
1243    H: ApiHandler,
1244{
1245    let path_name = parse_task_id("task name", path_name)?;
1246    reject_embedded_manifest(&manifest)?;
1247    if manifest.name() != &path_name {
1248        return Err(ApiError::InvalidRequest(format!(
1249            "path task name `{path_name}` does not match metadata.name `{}`",
1250            manifest.name()
1251        )));
1252    }
1253    let preconditions = parse_write_preconditions(params)?;
1254    debug!(name = %manifest.name(), "applying task");
1255    let task = public_task(handler.apply_task(manifest, preconditions).await?)?;
1256    Ok(Json(task))
1257}
1258
1259async fn get_task<H>(
1260    State(handler): State<Arc<H>>,
1261    ApiPath(TaskPath { name }): ApiPath<TaskPath>,
1262) -> Result<Json<Task>, ApiError>
1263where
1264    H: ApiHandler,
1265{
1266    let name = parse_task_id("task name", name)?;
1267    debug!(%name, "getting task");
1268    let task = handler
1269        .get_task(&name)
1270        .await?
1271        .ok_or_else(|| ApiError::TaskNotFound(name.to_string()))?;
1272
1273    Ok(Json(public_task(task)?))
1274}
1275
1276async fn list_tasks<H>(
1277    State(handler): State<Arc<H>>,
1278    RawQuery(raw_query): RawQuery,
1279) -> Result<Response, ApiError>
1280where
1281    H: ApiHandler,
1282{
1283    let ListTasksParams {
1284        slot,
1285        phases,
1286        label_selector,
1287        limit,
1288        continuation,
1289        resource_version,
1290        watch,
1291    } = parse_list_tasks_params(raw_query.as_deref())?;
1292    let filter = build_task_filter(slot, phases, label_selector)?;
1293
1294    if watch.unwrap_or(false) {
1295        if limit.is_some() || continuation.is_some() {
1296            return Err(ApiError::InvalidRequest(
1297                "query parameters `limit` and `continue` are not supported for watch".into(),
1298            ));
1299        }
1300        if resource_version
1301            .as_deref()
1302            .is_some_and(|value| value.trim().is_empty())
1303        {
1304            return Err(ApiError::InvalidRequest(
1305                "query parameter `resourceVersion` must not be empty".into(),
1306            ));
1307        }
1308
1309        let stream = handler.watch_tasks(filter, resource_version).await?;
1310        let body_stream = TaskWatchBodyStream::new(stream);
1311        let mut response = Body::from_stream(body_stream).into_response();
1312        response.extensions_mut().insert(StreamingResponse);
1313        response.headers_mut().insert(
1314            header::CONTENT_TYPE,
1315            HeaderValue::from_static("application/json"),
1316        );
1317        return Ok(response);
1318    }
1319
1320    if resource_version.is_some() {
1321        return Err(ApiError::InvalidRequest(
1322            "query parameter `resourceVersion` requires `watch=true`".into(),
1323        ));
1324    }
1325
1326    let mut query = TaskQuery::from_filter(filter);
1327    query = query.with_limit(parse_list_limit(limit.unwrap_or(0))?);
1328    if let Some(token) = continuation {
1329        if token.is_empty() {
1330            return Err(ApiError::InvalidRequest(
1331                "query parameter `continue` must not be empty".into(),
1332            ));
1333        }
1334        query = query.with_continuation(crate::continuation::decode(&token)?);
1335    }
1336
1337    let page_filter = query.filter().clone();
1338    let page_limit = query.limit();
1339    let page = handler.query_tasks(query).await?;
1340    crate::continuation::validate_page(&page, &page_filter, page_limit)?;
1341    debug!(
1342        count = page.items.len(),
1343        remaining = page.remaining_item_count,
1344        "tasks listed"
1345    );
1346
1347    for task in &page.items {
1348        if !task_is_visible(task) {
1349            return Err(ApiError::Internal(
1350                "handler returned an Embedded workload through the public HTTP API".into(),
1351            ));
1352        }
1353    }
1354
1355    let continuation = page
1356        .continuation
1357        .map(crate::continuation::encode)
1358        .transpose()?;
1359    Ok(Json(TaskList {
1360        api_version: TASK_API_VERSION,
1361        kind: "TaskList",
1362        metadata: ListMeta {
1363            resource_version: page.resource_version,
1364            continuation,
1365            remaining_item_count: (page.remaining_item_count > 0)
1366                .then_some(page.remaining_item_count),
1367        },
1368        items: page.items,
1369    })
1370    .into_response())
1371}
1372
1373async fn list_task_runs<H>(
1374    State(handler): State<Arc<H>>,
1375    ApiPath(TaskPath { name }): ApiPath<TaskPath>,
1376) -> Result<Json<TaskRunList>, ApiError>
1377where
1378    H: ApiHandler,
1379{
1380    let name = parse_task_id("task name", name)?;
1381    debug!(%name, "listing task runs");
1382    let runs = handler.list_task_runs(&name).await?;
1383    if runs.iter().any(|run| !run_is_visible(run)) {
1384        return Err(ApiError::Internal(
1385            "handler returned Embedded run history through the public HTTP API".into(),
1386        ));
1387    }
1388    Ok(Json(TaskRunList { runs }))
1389}
1390
1391async fn delete_task<H>(
1392    State(handler): State<Arc<H>>,
1393    ApiPath(TaskPath { name }): ApiPath<TaskPath>,
1394    ApiQuery(params): ApiQuery<WriteParams>,
1395) -> Result<NoContent, ApiError>
1396where
1397    H: ApiHandler,
1398{
1399    let name = parse_task_id("task name", name)?;
1400    let preconditions = parse_write_preconditions(params)?;
1401    handler.delete_task(&name, preconditions).await?;
1402    debug!(%name, "task deleted");
1403
1404    Ok(NoContent)
1405}
1406
1407/// Streams task output as Server-Sent Events.
1408async fn stream_task_logs<H>(
1409    State(handler): State<Arc<H>>,
1410    ApiPath(TaskPath { name }): ApiPath<TaskPath>,
1411) -> Result<Response, ApiError>
1412where
1413    H: ApiHandler,
1414{
1415    let name = parse_task_id("task name", name)?;
1416    debug!(%name, "subscribing to task log stream");
1417    let stream = handler.stream_task_logs(&name).await?;
1418
1419    let sse_stream = stream.map(|ev| {
1420        let name = match &ev {
1421            OutputEvent::Chunk(_) => "chunk",
1422            OutputEvent::RunStarted { .. } => "run-started",
1423            OutputEvent::RunFinished { .. } => "run-finished",
1424            OutputEvent::Lagged { .. } => "lagged",
1425            _ => "unknown",
1426        };
1427        let data = serde_json::to_string(&ev).map_err(|error| {
1428            ApiError::Internal(format!("failed to serialize output event: {error}"))
1429        })?;
1430        Ok::<Event, ApiError>(Event::default().event(name).data(data))
1431    });
1432    let mut response = Sse::new(sse_stream)
1433        .keep_alive(KeepAlive::default())
1434        .into_response();
1435    response.extensions_mut().insert(StreamingResponse);
1436    Ok(response)
1437}