Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion crates/api/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,27 @@
use actix_cors::Cors;
use actix_web::{web, App, HttpServer};
use actix_web::{error::InternalError, web, App, HttpResponse, HttpServer};
use kronos_common::config::{AppConfig, ServerMode};
use tracing_subscriber::EnvFilter;

/// Turn actix's default plaintext body-deserialization errors (e.g.
/// `Json deserialize error: premature end of input at line 1 column 75`)
/// into the structured `{ "error": { code, message } }` shape the rest of
/// the API uses, with a 400 status.
fn json_error_handler(
err: actix_web::error::JsonPayloadError,
_req: &actix_web::HttpRequest,
) -> actix_web::Error {
let message = format!("Malformed JSON request body: {err}");
let response = HttpResponse::BadRequest().json(serde_json::json!({
"error": {
"code": "INVALID_REQUEST",
"message": message,
"request_id": serde_json::Value::Null,
}
}));
InternalError::from_response(err, response).into()
}

mod dashboard;
mod extractors;
mod handlers;
Expand Down Expand Up @@ -67,6 +86,7 @@ async fn main() -> anyhow::Result<()> {

let mut app = App::new()
.app_data(web::Data::new(app_state.clone()))
.app_data(web::JsonConfig::default().error_handler(json_error_handler))
.wrap(cors)
.wrap(actix_web::middleware::Logger::default())
.wrap(crate::middleware::RequestId);
Expand Down
82 changes: 82 additions & 0 deletions crates/common/src/models/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,83 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;

/// Lenient deserialization for the timestamp fields on job creation/update.
///
/// HTML `<input type="datetime-local">` (used by the dashboard's job form)
/// emits values like `2026-06-09T17:18` — no seconds and no timezone offset.
/// chrono's default `DateTime<Utc>` deserializer only accepts full RFC 3339
/// (`2026-06-09T17:18:00Z`), so those values failed with the opaque
/// "premature end of input" serde error. This accepts both, treating
/// timezone-less values as UTC.
mod flexible_datetime {
use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
use serde::{Deserialize, Deserializer};

pub fn deserialize_opt<'de, D>(deserializer: D) -> Result<Option<DateTime<Utc>>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<String>::deserialize(deserializer)? {
None => Ok(None),
Some(s) if s.trim().is_empty() => Ok(None),
Some(s) => parse(s.trim()).map(Some).map_err(serde::de::Error::custom),
}
}

fn parse(s: &str) -> Result<DateTime<Utc>, String> {
// Absolute instant carrying an offset or `Z`.
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
// `datetime-local` wall-clock value, with or without seconds.
// Interpreted as UTC.
for fmt in ["%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M"] {
if let Ok(naive) = NaiveDateTime::parse_from_str(s, fmt) {
return Ok(Utc.from_utc_datetime(&naive));
}
}
Err(format!(
"invalid datetime '{s}': expected an RFC 3339 timestamp \
(e.g. 2026-06-09T17:18:00Z) or a datetime-local value \
(e.g. 2026-06-09T17:18)"
))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_datetime_local_without_seconds() {
assert_eq!(
parse("2026-06-09T17:18").unwrap(),
Utc.with_ymd_and_hms(2026, 6, 9, 17, 18, 0).unwrap()
);
}

#[test]
fn accepts_datetime_local_with_seconds() {
assert_eq!(
parse("2026-06-09T17:18:30").unwrap(),
Utc.with_ymd_and_hms(2026, 6, 9, 17, 18, 30).unwrap()
);
}

#[test]
fn accepts_rfc3339_with_offset() {
assert_eq!(
parse("2026-06-09T17:18:00+05:30").unwrap(),
Utc.with_ymd_and_hms(2026, 6, 9, 11, 48, 0).unwrap()
);
}

#[test]
fn rejects_garbage() {
assert!(parse("not a date").is_err());
}
}
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
// TODO 1: Use strum macros and serde to automatically make enums from strings
pub enum TriggerType {
Expand Down Expand Up @@ -74,10 +151,13 @@ pub struct CreateJob {
pub trigger: String,
pub idempotency_key: Option<String>,
pub input: Option<serde_json::Value>,
#[serde(default, deserialize_with = "flexible_datetime::deserialize_opt")]
pub run_at: Option<DateTime<Utc>>,
pub cron: Option<PgCronExpr>,
pub timezone: Option<String>,
#[serde(default, deserialize_with = "flexible_datetime::deserialize_opt")]
pub starts_at: Option<DateTime<Utc>>,
#[serde(default, deserialize_with = "flexible_datetime::deserialize_opt")]
pub ends_at: Option<DateTime<Utc>>,
}

Expand All @@ -86,6 +166,8 @@ pub struct UpdateJob {
pub cron: Option<PgCronExpr>,
pub timezone: Option<String>,
pub input: Option<serde_json::Value>,
#[serde(default, deserialize_with = "flexible_datetime::deserialize_opt")]
pub starts_at: Option<DateTime<Utc>>,
#[serde(default, deserialize_with = "flexible_datetime::deserialize_opt")]
pub ends_at: Option<DateTime<Utc>>,
}