Add the events API
One path with an EditScope on the write verbs. v1 had a second parallel tree at /api/calendar/events/series/* -- 1,165 lines mostly duplicating the non-series handlers, dispatching on string literals in 53 places where a typo was a runtime fallthrough. What a scoped edit means to the stored .ics lives in runway-core::series, pure and tested without a server, because that is the subtle part and v1 shipped it with no coverage at all. Editing one occurrence writes an override and no EXDATE: an EXDATE says the occurrence does not happen, an override says it happens differently, and writing both is contradictory. Deleting one writes the EXDATE and removes any override that named it. Splitting a series divides its bound rather than dropping it. Six weekly occurrences split at the third become two plus four, not two plus forever -- the count is what the person asked for and it should survive being cut. Overrides after the split move to the new series; moving a whole series shifts its overrides' RECURRENCE-IDs by the same amount instead of leaving them pointing at occurrences that no longer exist. Every write states a precondition. There is no unconditional path: an update without an ETag is refused, and a stale one is a conflict rather than a silent overwrite. UIDs are minted server-side, because a client-supplied one could collide with and replace an unrelated event. Reads use time-range and fan out across calendars concurrently. Zones that cannot be resolved are reported in the response instead of being rendered as though they were fine. Two tests found real bugs: sub-second timestamps cannot survive iCalendar's one-second resolution, and splitting at the first occurrence was dropping the recurrence rule and quietly turning a series into a single event.
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
use crate::routes;
|
||||
use crate::state::AppState;
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use axum::routing::{delete, get, post, put};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
pub fn router(state: AppState) -> Router {
|
||||
@@ -17,6 +17,12 @@ pub fn router(state: AppState) -> Router {
|
||||
.route("/api/auth/login", post(routes::auth::login))
|
||||
.route("/api/auth/logout", post(routes::auth::logout))
|
||||
.route("/api/auth/session", get(routes::auth::current_session))
|
||||
// One path for events, with an EditScope on the write verbs. v1 had a
|
||||
// second parallel tree at /api/calendar/events/series/*.
|
||||
.route("/api/events", get(routes::events::list))
|
||||
.route("/api/events", post(routes::events::create))
|
||||
.route("/api/events", put(routes::events::update))
|
||||
.route("/api/events", delete(routes::events::delete))
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
//! Reading and writing events.
|
||||
//!
|
||||
//! One path, `/api/events`, with an [`EditScope`] on the write verbs. v1 had a
|
||||
//! second parallel tree at `/api/calendar/events/series/*` — 1,165 lines that
|
||||
//! mostly duplicated the non-series handlers, dispatching on string literals
|
||||
//! like `"this_and_future"` in 53 places where a typo was a runtime
|
||||
//! fallthrough rather than a compile error.
|
||||
//!
|
||||
//! These handlers are deliberately thin. Deciding what a scoped edit means to
|
||||
//! the stored `.ics` is [`runway_core::series`], which is pure and tested
|
||||
//! without a server; expanding a rule is [`runway_core::recurrence`]. What is
|
||||
//! left here is moving bytes and reporting failures.
|
||||
|
||||
use crate::auth::CurrentUser;
|
||||
use crate::error::ApiError;
|
||||
use crate::state::AppState;
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
// axum's own Query goes through serde_urlencoded, which cannot collect a
|
||||
// repeated key into a Vec -- `?calendars=a&calendars=b` would fail to
|
||||
// deserialise. This one can.
|
||||
use axum_extra::extract::Query;
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
use chrono_tz::Tz;
|
||||
use runway_caldav::{CalDavClient, Precondition, href_for};
|
||||
use runway_core::model::{
|
||||
CalendarDateTime, CalendarObject, EditScope, Occurrence, VCalendar, VEvent,
|
||||
};
|
||||
use runway_core::recurrence::{Window, Zones, expand, instant_in, unresolved_zones};
|
||||
use runway_core::series::{DeleteOutcome, EditOutcome, apply_delete, apply_edit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::task::JoinSet;
|
||||
|
||||
// -------------------------------------------------------------------- reading
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RangeQuery {
|
||||
/// Inclusive start date.
|
||||
pub from: NaiveDate,
|
||||
/// Exclusive end date.
|
||||
pub to: NaiveDate,
|
||||
/// Restrict to these calendars. Repeat the parameter for several.
|
||||
#[serde(default)]
|
||||
pub calendars: Vec<String>,
|
||||
/// The zone to interpret the range in and render occurrences for.
|
||||
///
|
||||
/// The browser knows this and the stored preference can override it; see
|
||||
/// [`display_zone`].
|
||||
pub tz: Option<String>,
|
||||
}
|
||||
|
||||
/// One occurrence, with the CalDAV metadata needed to edit it.
|
||||
#[derive(Serialize)]
|
||||
pub struct PlacedOccurrence {
|
||||
pub calendar_href: String,
|
||||
/// The resource this came from. Sent back on an edit.
|
||||
pub href: String,
|
||||
/// The resource's `ETag`. Sent back on an edit, and the only thing standing
|
||||
/// between two people saving at once and one of them losing their work.
|
||||
pub etag: Option<String>,
|
||||
#[serde(flatten)]
|
||||
pub occurrence: Occurrence,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct EventsResponse {
|
||||
pub occurrences: Vec<PlacedOccurrence>,
|
||||
/// Zones a calendar named that could not be identified.
|
||||
///
|
||||
/// Surfaced rather than swallowed: times from an unresolved zone may be
|
||||
/// hours out, and the alternative is showing them as though they were fine.
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// `GET /api/events?from&to&calendars&tz`
|
||||
///
|
||||
/// Asks the CalDAV server for the objects touching the range — a `time-range`
|
||||
/// filter, which v1 never used — then expands them here.
|
||||
pub async fn list(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { user, .. }: CurrentUser,
|
||||
Query(query): Query<RangeQuery>,
|
||||
) -> Result<Json<EventsResponse>, ApiError> {
|
||||
if query.to <= query.from {
|
||||
return Err(ApiError::BadRequest(
|
||||
"the end of the range must be after its start".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let tz = display_zone(&state, &user, query.tz.as_deref()).await?;
|
||||
let zones = Zones::new(tz);
|
||||
let window = Window::new(
|
||||
instant_in(tz, midnight(query.from)),
|
||||
instant_in(tz, midnight(query.to)),
|
||||
);
|
||||
|
||||
let client = state.auth.caldav_for(&user).await?;
|
||||
let calendars: Vec<String> = client
|
||||
.discover()
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|c| c.supports_events())
|
||||
.filter(|c| query.calendars.is_empty() || query.calendars.contains(&c.href))
|
||||
.map(|c| c.href)
|
||||
.collect();
|
||||
|
||||
if calendars.is_empty() {
|
||||
return Err(ApiError::NotFound("no matching calendar".to_owned()));
|
||||
}
|
||||
|
||||
// Concurrently, because a week view over six calendars is six round trips
|
||||
// and doing them in turn is the difference between fast and sluggish.
|
||||
let mut fetches = JoinSet::new();
|
||||
for href in calendars {
|
||||
let client: CalDavClient = client.clone();
|
||||
fetches.spawn(async move {
|
||||
let objects = client.events_in_range(&href, window.from, window.to).await;
|
||||
(href, objects)
|
||||
});
|
||||
}
|
||||
|
||||
let mut occurrences = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
while let Some(joined) = fetches.join_next().await {
|
||||
let (href, objects) =
|
||||
joined.map_err(|error| ApiError::Internal(format!("fetch task failed: {error}")))?;
|
||||
|
||||
for object in objects? {
|
||||
for zone in unresolved_zones(&object.calendar, zones) {
|
||||
warnings.push(format!("{} names an unknown time zone {zone}", object.href));
|
||||
}
|
||||
for occurrence in expand(&object.calendar, window, zones)
|
||||
.map_err(|error| ApiError::Upstream(error.to_string()))?
|
||||
{
|
||||
occurrences.push(PlacedOccurrence {
|
||||
calendar_href: href.clone(),
|
||||
href: object.href.clone(),
|
||||
etag: object.etag.clone(),
|
||||
occurrence,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic order, so a re-fetch does not reshuffle the view.
|
||||
occurrences.sort_by(|a, b| {
|
||||
a.occurrence
|
||||
.start_utc
|
||||
.cmp(&b.occurrence.start_utc)
|
||||
.then_with(|| a.href.cmp(&b.href))
|
||||
});
|
||||
warnings.sort();
|
||||
warnings.dedup();
|
||||
|
||||
Ok(Json(EventsResponse {
|
||||
occurrences,
|
||||
warnings,
|
||||
}))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- writing
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateRequest {
|
||||
pub calendar_href: String,
|
||||
pub event: VEvent,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct WriteResponse {
|
||||
pub href: String,
|
||||
pub etag: Option<String>,
|
||||
/// Present when a "this and future" edit started a second series.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub new_series_href: Option<String>,
|
||||
}
|
||||
|
||||
/// `POST /api/events`
|
||||
pub async fn create(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { user, .. }: CurrentUser,
|
||||
Json(request): Json<CreateRequest>,
|
||||
) -> Result<Json<WriteResponse>, ApiError> {
|
||||
let client = state.auth.caldav_for(&user).await?;
|
||||
|
||||
// The UID is minted here. A client-supplied one could collide with, and
|
||||
// therefore overwrite, an unrelated event.
|
||||
let mut event = request.event;
|
||||
event.uid = uuid::Uuid::new_v4().to_string();
|
||||
event.recurrence_id = None;
|
||||
event.sequence = 0;
|
||||
|
||||
let href = href_for(&request.calendar_href, &event.uid);
|
||||
let calendar = VCalendar::with_events(vec![event]);
|
||||
|
||||
// If-None-Match: *, so a collision is refused rather than silently
|
||||
// replacing whatever was there.
|
||||
let etag = client
|
||||
.put_object(&href, &calendar, &Precondition::New)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WriteResponse {
|
||||
href,
|
||||
etag,
|
||||
new_series_href: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateRequest {
|
||||
pub calendar_href: String,
|
||||
pub href: String,
|
||||
/// The `ETag` the client last saw. Required.
|
||||
pub etag: String,
|
||||
#[serde(default)]
|
||||
pub scope: EditScope,
|
||||
/// Which occurrence, for a scoped edit.
|
||||
pub recurrence_id: Option<CalendarDateTime>,
|
||||
pub event: VEvent,
|
||||
}
|
||||
|
||||
/// `PUT /api/events`
|
||||
pub async fn update(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { user, .. }: CurrentUser,
|
||||
Json(request): Json<UpdateRequest>,
|
||||
) -> Result<Json<WriteResponse>, ApiError> {
|
||||
let client = state.auth.caldav_for(&user).await?;
|
||||
let zones = Zones::new(display_zone(&state, &user, None).await?);
|
||||
|
||||
let current = fetch(&client, &request.calendar_href, &request.href).await?;
|
||||
let outcome = apply_edit(
|
||||
¤t.calendar,
|
||||
request.scope,
|
||||
request.recurrence_id.as_ref(),
|
||||
&request.event,
|
||||
zones,
|
||||
)
|
||||
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
|
||||
|
||||
match outcome {
|
||||
EditOutcome::Replace(calendar) => {
|
||||
let etag = client
|
||||
.put_object(
|
||||
&request.href,
|
||||
&calendar,
|
||||
&Precondition::Unchanged(request.etag),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(WriteResponse {
|
||||
href: request.href,
|
||||
etag,
|
||||
new_series_href: None,
|
||||
}))
|
||||
}
|
||||
EditOutcome::Split {
|
||||
existing,
|
||||
new_series,
|
||||
} => {
|
||||
// Two writes. The truncation goes first: if the new series were
|
||||
// written first and the truncation then failed on a stale ETag, the
|
||||
// calendar would show both halves at once.
|
||||
let etag = client
|
||||
.put_object(
|
||||
&request.href,
|
||||
&existing,
|
||||
&Precondition::Unchanged(request.etag),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let uid = new_series
|
||||
.master()
|
||||
.map(|master| master.uid.clone())
|
||||
.ok_or_else(|| ApiError::Internal("split produced no master".to_owned()))?;
|
||||
let new_href = href_for(&request.calendar_href, &uid);
|
||||
client
|
||||
.put_object(&new_href, &new_series, &Precondition::New)
|
||||
.await?;
|
||||
|
||||
Ok(Json(WriteResponse {
|
||||
href: request.href,
|
||||
etag,
|
||||
new_series_href: Some(new_href),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteRequest {
|
||||
pub calendar_href: String,
|
||||
pub href: String,
|
||||
pub etag: String,
|
||||
#[serde(default)]
|
||||
pub scope: EditScope,
|
||||
pub recurrence_id: Option<CalendarDateTime>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DeleteResponse {
|
||||
/// Whether the whole resource went, or only part of the series.
|
||||
pub removed: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
/// `DELETE /api/events`
|
||||
///
|
||||
/// Takes a body, because a scoped delete needs a scope and a `RECURRENCE-ID`
|
||||
/// and neither belongs in a URL. RFC 9110 permits it.
|
||||
pub async fn delete(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser { user, .. }: CurrentUser,
|
||||
Json(request): Json<DeleteRequest>,
|
||||
) -> Result<Json<DeleteResponse>, ApiError> {
|
||||
let client = state.auth.caldav_for(&user).await?;
|
||||
let zones = Zones::new(display_zone(&state, &user, None).await?);
|
||||
|
||||
let current = fetch(&client, &request.calendar_href, &request.href).await?;
|
||||
let outcome = apply_delete(
|
||||
¤t.calendar,
|
||||
request.scope,
|
||||
request.recurrence_id.as_ref(),
|
||||
zones,
|
||||
)
|
||||
.map_err(|error| ApiError::BadRequest(error.to_string()))?;
|
||||
|
||||
match outcome {
|
||||
DeleteOutcome::Remove => {
|
||||
client
|
||||
.delete_object(&request.href, &Precondition::Unchanged(request.etag))
|
||||
.await?;
|
||||
Ok(Json(DeleteResponse {
|
||||
removed: true,
|
||||
etag: None,
|
||||
}))
|
||||
}
|
||||
DeleteOutcome::Replace(calendar) => {
|
||||
let etag = client
|
||||
.put_object(
|
||||
&request.href,
|
||||
&calendar,
|
||||
&Precondition::Unchanged(request.etag),
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(DeleteResponse {
|
||||
removed: false,
|
||||
etag,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- helpers
|
||||
|
||||
async fn fetch(
|
||||
client: &CalDavClient,
|
||||
calendar_href: &str,
|
||||
href: &str,
|
||||
) -> Result<CalendarObject, ApiError> {
|
||||
client
|
||||
.get_object(calendar_href, href)
|
||||
.await
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
/// The zone to render in.
|
||||
///
|
||||
/// The request wins, then the stored preference, then UTC. The request wins
|
||||
/// because the browser knows where it is right now and a stored preference can
|
||||
/// be months out of date — somebody who moves should not have to go and change
|
||||
/// a setting before their calendar makes sense.
|
||||
async fn display_zone(
|
||||
state: &AppState,
|
||||
user: &crate::db::User,
|
||||
requested: Option<&str>,
|
||||
) -> Result<Tz, ApiError> {
|
||||
if let Some(name) = requested {
|
||||
return name
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest(format!("unknown time zone {name:?}")));
|
||||
}
|
||||
|
||||
let preferences = state.db.preferences().load(&user.id).await?;
|
||||
Ok(preferences
|
||||
.display_timezone
|
||||
.and_then(|name| name.parse().ok())
|
||||
.unwrap_or(Tz::UTC))
|
||||
}
|
||||
|
||||
fn midnight(date: NaiveDate) -> chrono::NaiveDateTime {
|
||||
date.and_hms_opt(0, 0, 0)
|
||||
.unwrap_or_else(|| DateTime::<Utc>::UNIX_EPOCH.naive_utc())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! HTTP handlers.
|
||||
|
||||
pub mod auth;
|
||||
pub mod events;
|
||||
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
Reference in New Issue
Block a user