1use 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
95struct 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
118pub(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
145pub(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
225pub struct HttpApiParts {
230 pub router: Router,
232
233 pub openapi: OpenApi,
235}
236
237pub 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 pub fn new(handler: Arc<H>) -> Self {
278 Self {
279 handler,
280 metrics: noop_api_metrics(),
281 auth: None,
282 }
283 }
284
285 pub fn with_auth(mut self, token: Token) -> Self {
292 self.auth = Some(token);
293 self
294 }
295
296 pub fn with_metrics(mut self, metrics: ApiMetricsHandle) -> Self {
300 self.metrics = metrics;
301 self
302 }
303
304 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 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 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
555async 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 #[schemars(with = "Option<Slot>")]
577 slot: Option<String>,
578
579 #[schemars(rename = "phase", with = "Option<Vec<TaskPhase>>")]
581 phases: Vec<String>,
582
583 #[schemars(rename = "labelSelector")]
585 label_selector: Option<String>,
586
587 #[schemars(range(max = 1000))]
592 limit: Option<u32>,
593
594 #[schemars(rename = "continue", with = "Option<NonEmptyStringSchema>")]
596 continuation: Option<String>,
597
598 #[schemars(rename = "resourceVersion", with = "Option<NonEmptyStringSchema>")]
600 resource_version: Option<String>,
601
602 #[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 #[schemars(with = "Option<Uid>")]
677 uid: Option<String>,
678
679 #[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 #[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 resource_version: String,
803
804 #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
806 continuation: Option<String>,
807
808 #[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
1407async 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}