//! HTTP client for the `fil serve` daemon, shared by every surface that //! talks to it (the Elm TUI, the LSP, the CLI). The records it exchanges are //! `fil_api`'s. use std::{ path::{Path, PathBuf}, sync::Arc, time::Duration, }; use color_eyre::eyre::{Result, eyre}; use entity::{DateTime, NanoId}; use eventsource_stream::Eventsource as _; pub use fil_api::Snapshot; use fil_api::{ChangeSet, GroupDto, TagDto, TaskDto, ZettelDto}; use fil_core::{Priority, ZettelId}; use futures::{Stream, StreamExt as _}; use serde_json::json; use tokio::io::AsyncWriteExt as _; /// Header `GET /api/export` reports the archive's file count in; kept in /// step with `server::export::FILES_HEADER`. const EXPORT_FILES_HEADER: &str = "x-fil-export-files"; /// How long to wait for the daemon to accept a connection. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); /// How long any one read may stall; comfortably above the event stream's /// 15s keep-alive. const READ_TIMEOUT: Duration = Duration::from_secs(45); /// An inspector edit to send to the daemon. `is_group` picks between the /// task and group routes for fields both have. #[derive(Debug)] pub enum TodoUpdate { Name { id: NanoId, is_group: bool, name: String, }, Priority { id: NanoId, is_group: bool, priority: Priority, }, /// `None` clears the due date. Tasks only. Due { id: NanoId, due: Option, }, RandomColor { group_id: NanoId, }, } /// Something to delete through the daemon. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Deletion { /// A zettel that holds no task's or group's notes. Zettel(ZettelId), /// A task, with its zettel. Task(NanoId), /// A group, with everything beneath it. Group(NanoId), } /// One event from `GET /api/events`. #[derive(Debug)] pub enum StreamEvent { /// The daemon published changes. Changes(ChangeSet), /// The daemon dropped changes this subscriber was too slow to receive; /// fetch a fresh snapshot. Resync, } /// HTTP client for the `fil serve` daemon at `url`, with one method per /// endpoint. Cheap to clone (both fields are `Arc`-backed), so each request /// can run on its own task. #[derive(Debug, Clone)] pub struct Client { http: reqwest::Client, url: Arc, } impl Client { pub fn new(url: &str) -> Self { // The event stream carries a keep-alive comment every 15s, so a read // that stalls well past that means the daemon is gone without having // closed the connection (sleep, network loss); give up on it so the // stream reconnects. let http = reqwest::Client::builder() .connect_timeout(CONNECT_TIMEOUT) .read_timeout(READ_TIMEOUT) .build() .unwrap_or_default(); Self { http, url: url.trim_end_matches('/').into(), } } pub async fn fetch_snapshot(&self) -> Result { self.get_json("/api/snapshot").await } /// Every zettel's metadata; bodies come from [`Client::fetch_body`]. pub async fn fetch_zettels(&self) -> Result> { self.get_json("/api/zettels").await } /// The zettel's body: its file without the front matter. pub async fn fetch_body(&self, id: &ZettelId) -> Result { Ok(self .http .get(format!("{}/api/zettels/{id}/body", self.url)) .send() .await? .checked() .await? .text() .await?) } /// Ids of the zettels matching `query`, best match first. The daemon /// ranks them, since it has every body indexed. pub async fn search(&self, query: &str) -> Result> { Ok(self .http .get(format!("{}/api/zettels/search", self.url)) .query(&[("q", query)]) .send() .await? .checked() .await? .json() .await?) } /// The zettel with `id`, including where its file lives. pub async fn fetch_zettel(&self, id: &ZettelId) -> Result { self.get_json(&format!("/api/zettels/{id}")).await } pub async fn fetch_tags(&self) -> Result> { self.get_json("/api/tags").await } /// Opens `GET /api/events`. Resolves once the stream is open, then yields /// an item per event until the daemon closes it. Events that don't parse /// are skipped: the revision gap they leave triggers a resync. pub async fn events(&self) -> Result>> { let response = self .http .get(format!("{}/api/events", self.url)) .send() .await? .checked() .await?; Ok(response .bytes_stream() .eventsource() .filter_map(|event| async move { let event = match event { Ok(event) => event, Err(error) => return Some(Err(eyre!("event stream: {error}"))), }; match event.event.as_str() { "changes" => serde_json::from_str(&event.data) .ok() .map(|set| Ok(StreamEvent::Changes(set))), "resync" => Some(Ok(StreamEvent::Resync)), _ => None, } })) } /// Downloads an archive of the whole vault (see `fil import`) to `dest`, /// via `dest.partial` so a failed download never leaves a truncated /// archive behind. Returns how many files it holds. pub async fn export(&self, dest: &Path) -> Result { let response = self .http .get(format!("{}/api/export", self.url)) .send() .await? .checked() .await?; let files = response .headers() .get(EXPORT_FILES_HEADER) .and_then(|count| count.to_str().ok()?.parse().ok()) .unwrap_or_default(); let mut partial = dest.as_os_str().to_owned(); partial.push(".partial"); let partial = PathBuf::from(partial); let written = async { let mut file = tokio::fs::File::create(&partial).await?; let mut chunks = response.bytes_stream(); while let Some(chunk) = chunks.next().await { file.write_all(&chunk?).await?; } file.flush().await?; tokio::fs::rename(&partial, dest).await?; Ok::<_, color_eyre::Report>(()) } .await; if written.is_err() { let _ = tokio::fs::remove_file(&partial).await; } written.map(|()| files) } pub async fn fetch_file(&self, id: &ZettelId) -> Result { let contents = self .http .get(format!("{}/api/zettels/{id}/file", self.url)) .send() .await? .checked() .await? .text() .await?; Ok(contents) } pub async fn save_file(&self, id: &ZettelId, contents: String) -> Result { let zettel = self .http .put(format!("{}/api/zettels/{id}/file", self.url)) .body(contents) .send() .await? .checked() .await? .json::() .await?; Ok(zettel) } pub async fn create_zettel(&self, title: String) -> Result { let zettel = self .http .post(format!("{}/api/zettels", self.url)) .json(&json!({ "title": title, "tags": [] })) .send() .await? .checked() .await? .json::() .await?; Ok(zettel) } pub async fn create_task(&self, name: String, group_id: NanoId) -> Result { let task = self .http .post(format!("{}/api/todo/task", self.url)) .json(&json!({ "name": name, "parent_id": group_id, "due": null, "priority": Priority::default(), })) .send() .await? .checked() .await? .json::() .await?; Ok(task) } pub async fn create_group(&self, name: String, parent_id: Option) -> Result { let group = self .http .post(format!("{}/api/todo/group", self.url)) .json(&json!({ "name": name, "parent_id": parent_id })) .send() .await? .checked() .await? .json::() .await?; Ok(group) } pub async fn update_todo(&self, update: &TodoUpdate) -> Result<()> { let kind = |is_group| if is_group { "group" } else { "task" }; let request = match update { TodoUpdate::Name { id, is_group, name } => self .http .put(format!( "{}/api/todo/{}/{id}/name", self.url, kind(*is_group) )) .json(&json!({ "name": name })), TodoUpdate::Priority { id, is_group, priority, } => self .http .put(format!( "{}/api/todo/{}/{id}/priority", self.url, kind(*is_group) )) .json(&json!({ "priority": priority })), TodoUpdate::Due { id, due } => self .http .put(format!("{}/api/todo/task/{id}/due", self.url)) .json(&json!({ "due": due })), TodoUpdate::RandomColor { group_id } => self.http.post(format!( "{}/api/todo/group/{group_id}/randomize-color", self.url )), }; request.send().await?.checked().await?; Ok(()) } pub async fn delete(&self, deletion: &Deletion) -> Result<()> { let path = match deletion { Deletion::Zettel(id) => format!("/api/zettels/{id}"), Deletion::Task(id) => format!("/api/todo/task/{id}"), Deletion::Group(id) => format!("/api/todo/group/{id}"), }; self.http .delete(format!("{}{path}", self.url)) .send() .await? .checked() .await?; Ok(()) } pub async fn toggle_task(&self, id: &NanoId) -> Result<()> { self.http .post(format!("{}/api/todo/task/{id}/toggle", self.url)) .send() .await? .checked() .await?; Ok(()) } } impl Client { async fn get_json(&self, path: &str) -> Result { Ok(self .http .get(format!("{}{path}", self.url)) .send() .await? .checked() .await? .json::() .await?) } } trait Checked { /// Like `error_for_status`, but keeps the daemon's explanation (the /// response body), e.g. `404 Not Found: no group with id …`. async fn checked(self) -> Result; } impl Checked for reqwest::Response { async fn checked(self) -> Result { let status = self.status(); if !status.is_client_error() && !status.is_server_error() { return Ok(self); } let body = self.text().await.unwrap_or_default(); let body = body.trim(); if body.is_empty() { Err(eyre!("daemon replied {status}")) } else { Err(eyre!("daemon replied {status}: {body}")) } } }