rust-job-queue-api-worker-system 0.1.0

A production-shaped Rust job queue: Axum API + async workers + Postgres SKIP LOCKED dequeue, retries with decorrelated jitter, idempotency, cooperative cancellation, OpenAPI, Prometheus metrics.
//! HTTP request and response shapes.
//!
//! These types are *separate* from the [`crate::domain`] types on
//! purpose:
//!
//! - The HTTP contract should be allowed to evolve independently of the
//!   DB schema. Adding a derived field to a response (e.g., `duration_ms`
//!   computed from `started_at` and `completed_at`) shouldn't force a
//!   migration; renaming a DB column shouldn't break public clients.
//! - The HTTP shapes are decorated for OpenAPI (`utoipa::ToSchema`,
//!   `utoipa::IntoParams`) but the domain types do not need every
//!   variation of that machinery — keeping the OpenAPI annotations
//!   isolated here keeps the domain types clean.
//! - `CreateJobRequest` is a strict subset of the domain `NewJob` —
//!   API consumers should not be able to set fields like `attempts` or
//!   `status` even by accident. A separate DTO makes that impossible.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::domain::{Job, JobKind, JobStatus};
use crate::ids::JobId;

/// Body of `POST /jobs`. The handler combines this with the optional
/// `Idempotency-Key` HTTP header (header takes precedence over the body
/// field).
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
pub struct CreateJobRequest {
    /// The kind of job to enqueue. Constrains the expected `payload` shape.
    pub kind: JobKind,
    /// Free-form JSON payload. Must match the schema for `kind`
    /// (validated against [`crate::payload`] before insert).
    #[schema(value_type = Object)]
    pub payload: serde_json::Value,
    /// Number of attempts before the job lands in `failed_permanent`.
    /// Defaults to 3 if omitted.
    #[serde(default)]
    pub max_attempts: Option<i32>,
    /// Optional idempotency key. The `Idempotency-Key` HTTP header
    /// takes precedence if both are supplied.
    #[serde(default)]
    pub idempotency_key: Option<String>,
}

/// Wire representation of a job for `GET /jobs/{id}`, `GET /jobs`, and
/// `POST /jobs` responses.
///
/// Mirrors [`Job`] field-for-field — we expose everything the row holds.
/// The mapping is straightforward via the `From<Job>` impl below.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct JobResponse {
    pub id: JobId,
    pub kind: JobKind,
    pub status: JobStatus,
    pub attempts: i32,
    pub max_attempts: i32,
    pub last_error: Option<String>,
    #[schema(value_type = Object)]
    pub payload: serde_json::Value,
    pub run_at: DateTime<Utc>,
    pub idempotency_key: Option<String>,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

/// Domain → wire conversion. Lossless; every field on `Job` is exposed
/// on `JobResponse`. The two `locked_*` columns are dropped because
/// they're operational diagnostics (worker id, lock timestamp) that
/// the API doesn't need to surface to clients.
impl From<Job> for JobResponse {
    fn from(j: Job) -> Self {
        Self {
            id: j.id,
            kind: j.kind,
            status: j.status,
            attempts: j.attempts,
            max_attempts: j.max_attempts,
            last_error: j.last_error,
            payload: j.payload,
            run_at: j.run_at,
            idempotency_key: j.idempotency_key,
            created_at: j.created_at,
            updated_at: j.updated_at,
        }
    }
}

/// Query parameters for `GET /jobs`.
///
/// `IntoParams` (instead of `ToSchema`) makes utoipa render these as
/// individual query parameters in the OpenAPI spec rather than as a
/// JSON body schema.
#[derive(Debug, Clone, Deserialize, utoipa::IntoParams)]
pub struct ListJobsQuery {
    pub status: Option<JobStatus>,
    pub kind: Option<JobKind>,
    pub limit: Option<i64>,
    pub offset: Option<i64>,
}

/// Body of the cancellation response.
///
/// `status` is one of `"cancelled"` (HTTP 200, was queued/retrying) or
/// `"pending"` (HTTP 202, was running and the flag was set).
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct CancelResponse {
    /// One of: `cancelled`, `pending`.
    pub status: &'static str,
    pub job_id: JobId,
}

/// Body of the health-check response.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct HealthResponse {
    pub status: &'static str,
}

/// Standard error-response body used by every 4xx/5xx response from
/// the API.
///
/// The two-field shape (machine slug + human message) lets clients
/// branch programmatically on `error` without parsing prose, and
/// surfaces a useful `message` for end-users when the slug isn't
/// enough.
#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
pub struct ErrorBody {
    /// Machine-friendly slug, e.g. `not_found`, `validation`, `conflict`.
    /// Clients can pattern-match against these.
    pub error: &'static str,
    /// Human-readable explanation. Safe to surface in API consumers' UIs;
    /// 5xx variants are redacted to "internal server error" so internals
    /// are not leaked.
    pub message: String,
}