use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use crate::api::dto::{CancelResponse, CreateJobRequest, JobResponse, ListJobsQuery};
use crate::api::error::ApiError;
use crate::api::AppState;
use crate::domain::NewJob;
use crate::ids::JobId;
use crate::queue::{self, CancelOutcome, ListFilter};
const IDEMPOTENCY_HEADER: &str = "idempotency-key";
#[utoipa::path(
post,
path = "/jobs",
request_body = CreateJobRequest,
responses(
(status = 201, description = "Job created", body = JobResponse),
(status = 200, description = "Existing job returned via idempotency key", body = JobResponse),
(status = 422, description = "Payload validation failed", body = crate::api::dto::ErrorBody),
(status = 500, description = "Internal error", body = crate::api::dto::ErrorBody),
),
tag = "jobs",
)]
pub async fn create_job(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<CreateJobRequest>,
) -> Result<(StatusCode, Json<JobResponse>), ApiError> {
let header_key = headers
.get(IDEMPOTENCY_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let idempotency_key = header_key.or(body.idempotency_key);
let new = NewJob {
kind: body.kind,
payload: body.payload,
max_attempts: body.max_attempts,
idempotency_key,
};
let outcome = queue::enqueue(&state.pool, new).await?;
let status = if outcome.is_new() {
StatusCode::CREATED
} else {
StatusCode::OK
};
Ok((status, Json(JobResponse::from(outcome.job().clone()))))
}
#[utoipa::path(
get,
path = "/jobs/{id}",
params(("id" = String, Path, description = "Job UUID")),
responses(
(status = 200, description = "Job representation", body = JobResponse),
(status = 404, description = "Job not found", body = crate::api::dto::ErrorBody),
),
tag = "jobs",
)]
pub async fn get_job(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<Json<JobResponse>, ApiError> {
let job = queue::get(&state.pool, JobId::from_uuid(id))
.await?
.ok_or(ApiError::NotFound)?;
Ok(Json(JobResponse::from(job)))
}
#[utoipa::path(
get,
path = "/jobs",
params(ListJobsQuery),
responses(
(status = 200, description = "Paginated job list (reverse chronological)", body = [JobResponse]),
),
tag = "jobs",
)]
pub async fn list_jobs(
State(state): State<AppState>,
Query(q): Query<ListJobsQuery>,
) -> Result<Json<Vec<JobResponse>>, ApiError> {
let filter = ListFilter {
status: q.status,
kind: q.kind,
limit: q.limit.unwrap_or(50),
offset: q.offset.unwrap_or(0),
};
let jobs = queue::list(&state.pool, filter).await?;
Ok(Json(jobs.into_iter().map(JobResponse::from).collect()))
}
#[utoipa::path(
post,
path = "/jobs/{id}/cancel",
params(("id" = String, Path, description = "Job UUID")),
responses(
(status = 200, description = "Cancelled immediately (was queued/retrying)", body = CancelResponse),
(status = 202, description = "Cancel signalled to running worker", body = CancelResponse),
(status = 404, description = "Job not found", body = crate::api::dto::ErrorBody),
(status = 409, description = "Job already in terminal state", body = crate::api::dto::ErrorBody),
),
tag = "jobs",
)]
pub async fn cancel_job(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
) -> Result<(StatusCode, Json<CancelResponse>), ApiError> {
let job_id = JobId::from_uuid(id);
let outcome = queue::request_cancel(&state.pool, job_id).await?;
let (status, slug) = match outcome {
CancelOutcome::CancelledNow => (StatusCode::OK, "cancelled"),
CancelOutcome::PendingOnWorker => (StatusCode::ACCEPTED, "pending"),
CancelOutcome::AlreadyTerminal(s) => {
return Err(ApiError::Conflict(format!(
"job is already in terminal state {}",
s.as_str()
)));
}
};
Ok((
status,
Json(CancelResponse {
status: slug,
job_id,
}),
))
}