Split the server into three crates (#56)

This will make it easier to build variations on the server, or embed it
into larger projects.
This commit is contained in:
Dustin J. Mitchell 2024-11-17 15:12:42 -05:00 committed by GitHub
parent 5769781553
commit 47ce4c1e3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1243 additions and 57 deletions

View file

@ -62,11 +62,24 @@ jobs:
override: true override: true
minimal: true minimal: true
- uses: actions-rs/cargo@v1.0.3 - name: taskchampion-sync-server
uses: actions-rs/cargo@v1.0.3
with: with:
command: rustdoc command: rustdoc
args: -p taskchampion-sync-server --all-features -- -Z unstable-options --check -Dwarnings args: -p taskchampion-sync-server --all-features -- -Z unstable-options --check -Dwarnings
- name: taskchampion-sync-server-core
uses: actions-rs/cargo@v1.0.3
with:
command: rustdoc
args: -p taskchampion-sync-server-core --all-features -- -Z unstable-options --check -Dwarnings
- name: taskchampion-sync-server-storage-sqlite
uses: actions-rs/cargo@v1.0.3
with:
command: rustdoc
args: -p taskchampion-sync-server-storage-sqlite --all-features -- -Z unstable-options --check -Dwarnings
fmt: fmt:
runs-on: ubuntu-latest runs-on: ubuntu-latest
name: "Formatting" name: "Formatting"

41
Cargo.lock generated
View file

@ -687,9 +687,9 @@ dependencies = [
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.30" version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]] [[package]]
name = "futures-macro" name = "futures-macro"
@ -1406,9 +1406,36 @@ dependencies = [
"futures", "futures",
"log", "log",
"pretty_assertions", "pretty_assertions",
"rusqlite",
"serde", "serde",
"serde_json", "serde_json",
"taskchampion-sync-server-core",
"taskchampion-sync-server-storage-sqlite",
"tempfile",
"thiserror",
"uuid",
]
[[package]]
name = "taskchampion-sync-server-core"
version = "0.4.1"
dependencies = [
"anyhow",
"chrono",
"env_logger",
"log",
"pretty_assertions",
"uuid",
]
[[package]]
name = "taskchampion-sync-server-storage-sqlite"
version = "0.4.1"
dependencies = [
"anyhow",
"chrono",
"pretty_assertions",
"rusqlite",
"taskchampion-sync-server-core",
"tempfile", "tempfile",
"thiserror", "thiserror",
"uuid", "uuid",
@ -1429,18 +1456,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.0" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15291287e9bff1bc6f9ff3409ed9af665bec7a5fc8ac079ea96be07bca0e2668" checksum = "c006c85c7651b3cf2ada4584faa36773bd07bac24acfb39f3c431b36d7e667aa"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.0" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22efd00f33f93fa62848a7cab956c3d38c8d43095efda1decfc2b3a5dc0b8972" checksum = "f077553d607adc1caf65430528a576c757a71ed73944b66ebb58ef2bbd243568"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -1,11 +1,12 @@
[package] [workspace]
name = "taskchampion-sync-server" resolver = "2"
version = "0.4.1" members = [
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"] "core",
edition = "2021" "server",
publish = false "sqlite",
]
[dependencies] [workspace.dependencies]
uuid = { version = "^1.11.0", features = ["serde", "v4"] } uuid = { version = "^1.11.0", features = ["serde", "v4"] }
actix-web = "^4.9.0" actix-web = "^4.9.0"
anyhow = "1.0" anyhow = "1.0"
@ -18,8 +19,6 @@ log = "^0.4.17"
env_logger = "^0.11.5" env_logger = "^0.11.5"
rusqlite = { version = "0.32", features = ["bundled"] } rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "^0.4.38", features = ["serde"] } chrono = { version = "^0.4.38", features = ["serde"] }
[dev-dependencies]
actix-rt = "2" actix-rt = "2"
tempfile = "3" tempfile = "3"
pretty_assertions = "1" pretty_assertions = "1"

View file

@ -13,6 +13,12 @@ This repository was spun off from Taskwarrior itself after the 3.0.0
release. It is still under development and currently best described as release. It is still under development and currently best described as
a reference implementation of the Taskchampion sync protocol. a reference implementation of the Taskchampion sync protocol.
It is comprised of three crates:
- `taskchampion-sync-server-core` implements the core of the protocol
- `taskchmpaion-sync-server-sqlite` implements an SQLite backend for the core
- `taskchampion-sync-server` implements a simple HTTP server for the protocol
## Installation ## Installation
### From binary ### From binary

15
core/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "taskchampion-sync-server-core"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
[dependencies]
uuid.workspace = true
anyhow.workspace = true
log.workspace = true
env_logger.workspace = true
chrono.workspace = true
[dev-dependencies]
pretty_assertions.workspace = true

8
core/README.md Normal file
View file

@ -0,0 +1,8 @@
# taskchampion-sync-server-core
This crate implements the core logic of the taskchampion sync protocol.
This should be considered a reference implementation, with [the protocol
documentation](https://gothenburgbitfactory.org/taskchampion/sync-protocol.html).
representing the authoritative definition of the protocol. Other
implementations are encouraged.

View file

@ -1,6 +1,7 @@
use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; use super::{Client, Snapshot, Storage, StorageTxn, Version};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard}; use std::sync::{Mutex, MutexGuard};
use uuid::Uuid;
struct Inner { struct Inner {
/// Clients, indexed by client_id /// Clients, indexed by client_id
@ -16,6 +17,11 @@ struct Inner {
children: HashMap<(Uuid, Uuid), Uuid>, children: HashMap<(Uuid, Uuid), Uuid>,
} }
/// In-memory storage for testing and experimentation.
///
/// This is not for production use, but supports testing of sync server implementations.
///
/// NOTE: this does not implement transaction rollback.
pub struct InMemoryStorage(Mutex<Inner>); pub struct InMemoryStorage(Mutex<Inner>);
impl InMemoryStorage { impl InMemoryStorage {
@ -32,9 +38,6 @@ impl InMemoryStorage {
struct InnerTxn<'a>(MutexGuard<'a, Inner>); struct InnerTxn<'a>(MutexGuard<'a, Inner>);
/// In-memory storage for testing and experimentation.
///
/// NOTE: this does not implement transaction rollback.
impl Storage for InMemoryStorage { impl Storage for InMemoryStorage {
fn txn<'a>(&'a self) -> anyhow::Result<Box<dyn StorageTxn + 'a>> { fn txn<'a>(&'a self) -> anyhow::Result<Box<dyn StorageTxn + 'a>> {
Ok(Box::new(InnerTxn(self.0.lock().expect("poisoned lock")))) Ok(Box::new(InnerTxn(self.0.lock().expect("poisoned lock"))))

32
core/src/lib.rs Normal file
View file

@ -0,0 +1,32 @@
//! This crate implements the core logic of the taskchampion sync protocol.
//!
//! This should be considered a reference implementation, with [the protocol
//! documentation](https://gothenburgbitfactory.org/taskchampion/sync-protocol.html). representing
//! the authoritative definition of the protocol. Other implementations are encouraged.
//!
//! This crate uses an abstract storage backend. Note that this does not implement the
//! HTTP-specific portions of the protocol, nor provide any storage implementations.
//!
//! ## API Methods
//!
//! The following API methods are implemented. These methods are documented in more detail in
//! the protocol documentation.
//!
//! * [`add_version`]
//! * [`get_child_version`]
//! * [`add_snapshot`]
//! * [`get_snapshot`]
//!
//! Each API method takes:
//!
//! * [`StorageTxn`] to access storage. Methods which modify storage will commit the transaction before returning.
//! * [`ServerConfig`] providing basic configuration for the server's behavior.
//! * `client_id` and a [`Client`] providing the client metadata.
mod inmemory;
mod server;
mod storage;
pub use inmemory::*;
pub use server::*;
pub use storage::*;

1037
core/src/server.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,15 +1,7 @@
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use uuid::Uuid; use uuid::Uuid;
#[cfg(debug_assertions)] /// A representation of stored metadata about a client.
mod inmemory;
#[cfg(debug_assertions)]
pub use inmemory::InMemoryStorage;
mod sqlite;
pub use self::sqlite::SqliteStorage;
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Client { pub struct Client {
/// The latest version for this client (may be the nil version) /// The latest version for this client (may be the nil version)
@ -18,6 +10,7 @@ pub struct Client {
pub snapshot: Option<Snapshot>, pub snapshot: Option<Snapshot>,
} }
/// Metadata about a snapshot, not including the snapshot data itself.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Snapshot { pub struct Snapshot {
/// ID of the version at which this snapshot was made /// ID of the version at which this snapshot was made
@ -32,11 +25,19 @@ pub struct Snapshot {
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct Version { pub struct Version {
/// The uuid identifying this version.
pub version_id: Uuid, pub version_id: Uuid,
/// The uuid identifying this version's parent.
pub parent_version_id: Uuid, pub parent_version_id: Uuid,
/// The data carried in this version.
pub history_segment: Vec<u8>, pub history_segment: Vec<u8>,
} }
/// A transaction in the storage backend.
///
/// Transactions must be sequentially consistent. That is, the results of transactions performed
/// in storage must be as if each were executed sequentially in some order. In particular, the
/// `Client.latest_version` must not change between a call to `get_client` and `add_version`.
pub trait StorageTxn { pub trait StorageTxn {
/// Get information about the given client /// Get information about the given client
fn get_client(&mut self, client_id: Uuid) -> anyhow::Result<Option<Client>>; fn get_client(&mut self, client_id: Uuid) -> anyhow::Result<Option<Client>>;

26
server/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "taskchampion-sync-server"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
publish = false
[dependencies]
taskchampion-sync-server-core = { path = "../core" }
taskchampion-sync-server-storage-sqlite = { path = "../sqlite" }
uuid.workspace = true
actix-web.workspace = true
anyhow.workspace = true
thiserror.workspace = true
futures.workspace = true
serde_json.workspace = true
serde.workspace = true
clap.workspace = true
log.workspace = true
env_logger.workspace = true
chrono.workspace = true
[dev-dependencies]
actix-rt.workspace = true
tempfile.workspace = true
pretty_assertions.workspace = true

View file

@ -1,8 +1,8 @@
use crate::api::{client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE}; use crate::api::{client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE};
use crate::server::{add_snapshot, VersionId, NIL_VERSION_ID};
use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result};
use futures::StreamExt; use futures::StreamExt;
use std::sync::Arc; use std::sync::Arc;
use taskchampion_sync_server_core::{add_snapshot, VersionId, NIL_VERSION_ID};
/// Max snapshot size: 100MB /// Max snapshot size: 100MB
const MAX_SIZE: usize = 100 * 1024 * 1024; const MAX_SIZE: usize = 100 * 1024 * 1024;
@ -77,10 +77,10 @@ pub(crate) async fn service(
mod test { mod test {
use super::*; use super::*;
use crate::api::CLIENT_ID_HEADER; use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server; use crate::Server;
use actix_web::{http::StatusCode, test, App}; use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage};
use uuid::Uuid; use uuid::Uuid;
#[actix_rt::test] #[actix_rt::test]

View file

@ -2,10 +2,12 @@ use crate::api::{
client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE,
PARENT_VERSION_ID_HEADER, SNAPSHOT_REQUEST_HEADER, VERSION_ID_HEADER, PARENT_VERSION_ID_HEADER, SNAPSHOT_REQUEST_HEADER, VERSION_ID_HEADER,
}; };
use crate::server::{add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID};
use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result};
use futures::StreamExt; use futures::StreamExt;
use std::sync::Arc; use std::sync::Arc;
use taskchampion_sync_server_core::{
add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID,
};
/// Max history segment size: 100MB /// Max history segment size: 100MB
const MAX_SIZE: usize = 100 * 1024 * 1024; const MAX_SIZE: usize = 100 * 1024 * 1024;
@ -105,10 +107,10 @@ pub(crate) async fn service(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::api::CLIENT_ID_HEADER; use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server; use crate::Server;
use actix_web::{http::StatusCode, test, App}; use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage};
use uuid::Uuid; use uuid::Uuid;
#[actix_rt::test] #[actix_rt::test]

View file

@ -2,9 +2,9 @@ use crate::api::{
client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, client_id_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE,
PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER, PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER,
}; };
use crate::server::{get_child_version, GetVersionResult, VersionId};
use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
use std::sync::Arc; use std::sync::Arc;
use taskchampion_sync_server_core::{get_child_version, GetVersionResult, VersionId};
/// Get a child version. /// Get a child version.
/// ///
@ -57,11 +57,10 @@ pub(crate) async fn service(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::api::CLIENT_ID_HEADER; use crate::api::CLIENT_ID_HEADER;
use crate::server::NIL_VERSION_ID;
use crate::storage::{InMemoryStorage, Storage};
use crate::Server; use crate::Server;
use actix_web::{http::StatusCode, test, App}; use actix_web::{http::StatusCode, test, App};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Storage, NIL_VERSION_ID};
use uuid::Uuid; use uuid::Uuid;
#[actix_rt::test] #[actix_rt::test]

View file

@ -1,9 +1,9 @@
use crate::api::{ use crate::api::{
client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE, VERSION_ID_HEADER, client_id_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE, VERSION_ID_HEADER,
}; };
use crate::server::get_snapshot;
use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; use actix_web::{error, get, web, HttpRequest, HttpResponse, Result};
use std::sync::Arc; use std::sync::Arc;
use taskchampion_sync_server_core::get_snapshot;
/// Get a snapshot. /// Get a snapshot.
/// ///
@ -42,11 +42,11 @@ pub(crate) async fn service(
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use crate::api::CLIENT_ID_HEADER; use crate::api::CLIENT_ID_HEADER;
use crate::storage::{InMemoryStorage, Snapshot, Storage};
use crate::Server; use crate::Server;
use actix_web::{http::StatusCode, test, App}; use actix_web::{http::StatusCode, test, App};
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::{InMemoryStorage, Snapshot, Storage};
use uuid::Uuid; use uuid::Uuid;
#[actix_rt::test] #[actix_rt::test]

View file

@ -1,7 +1,5 @@
use crate::server::ClientId;
use crate::storage::Storage;
use crate::ServerConfig;
use actix_web::{error, http::StatusCode, web, HttpRequest, Result, Scope}; use actix_web::{error, http::StatusCode, web, HttpRequest, Result, Scope};
use taskchampion_sync_server_core::{ClientId, ServerConfig, Storage};
mod add_snapshot; mod add_snapshot;
mod add_version; mod add_version;

View file

@ -3,8 +3,9 @@
use actix_web::{middleware::Logger, App, HttpServer}; use actix_web::{middleware::Logger, App, HttpServer};
use clap::{arg, builder::ValueParser, value_parser, Command}; use clap::{arg, builder::ValueParser, value_parser, Command};
use std::ffi::OsString; use std::ffi::OsString;
use taskchampion_sync_server::storage::SqliteStorage; use taskchampion_sync_server::Server;
use taskchampion_sync_server::{Server, ServerConfig}; use taskchampion_sync_server_core::ServerConfig;
use taskchampion_sync_server_storage_sqlite::SqliteStorage;
#[actix_web::main] #[actix_web::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
@ -62,7 +63,7 @@ async fn main() -> anyhow::Result<()> {
mod test { mod test {
use super::*; use super::*;
use actix_web::{test, App}; use actix_web::{test, App};
use taskchampion_sync_server::storage::InMemoryStorage; use taskchampion_sync_server_core::InMemoryStorage;
#[actix_rt::test] #[actix_rt::test]
async fn test_index_get() { async fn test_index_get() {

View file

@ -1,15 +1,11 @@
#![deny(clippy::all)] #![deny(clippy::all)]
mod api; mod api;
mod server;
pub mod storage;
use crate::storage::Storage;
use actix_web::{get, middleware, web, Responder}; use actix_web::{get, middleware, web, Responder};
use api::{api_scope, ServerState}; use api::{api_scope, ServerState};
use std::sync::Arc; use std::sync::Arc;
use taskchampion_sync_server_core::{ServerConfig, Storage};
pub use server::ServerConfig;
#[get("/")] #[get("/")]
async fn index() -> impl Responder { async fn index() -> impl Responder {
@ -47,13 +43,9 @@ impl Server {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::storage::InMemoryStorage;
use actix_web::{test, App}; use actix_web::{test, App};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use taskchampion_sync_server_core::InMemoryStorage;
pub(crate) fn init_logging() {
let _ = env_logger::builder().is_test(true).try_init();
}
#[actix_rt::test] #[actix_rt::test]
async fn test_cache_control() { async fn test_cache_control() {

17
sqlite/Cargo.toml Normal file
View file

@ -0,0 +1,17 @@
[package]
name = "taskchampion-sync-server-storage-sqlite"
version = "0.4.1"
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2021"
[dependencies]
taskchampion-sync-server-core = { path = "../core" }
uuid.workspace = true
anyhow.workspace = true
thiserror.workspace = true
rusqlite.workspace = true
chrono.workspace = true
[dev-dependencies]
tempfile.workspace = true
pretty_assertions.workspace = true

4
sqlite/README.md Normal file
View file

@ -0,0 +1,4 @@
# taskchampion-sync-server-storage-sqlite
This crate implements a SQLite storage backend for the
`taskchampion-sync-server-core`.

View file

@ -1,9 +1,11 @@
use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; //! Tihs crate implements a SQLite storage backend for the TaskChampion sync server.
use anyhow::Context; use anyhow::Context;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use rusqlite::types::{FromSql, ToSql}; use rusqlite::types::{FromSql, ToSql};
use rusqlite::{params, Connection, OptionalExtension}; use rusqlite::{params, Connection, OptionalExtension};
use std::path::Path; use std::path::Path;
use taskchampion_sync_server_core::{Client, Snapshot, Storage, StorageTxn, Version};
use uuid::Uuid;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
enum SqliteError { enum SqliteError {
@ -31,7 +33,7 @@ impl ToSql for StoredUuid {
} }
} }
/// An on-disk storage backend which uses SQLite /// An on-disk storage backend which uses SQLite.
pub struct SqliteStorage { pub struct SqliteStorage {
db_file: std::path::PathBuf, db_file: std::path::PathBuf,
} }
@ -41,6 +43,10 @@ impl SqliteStorage {
Ok(Connection::open(&self.db_file)?) Ok(Connection::open(&self.db_file)?)
} }
/// Create a new instance using a database at the given directory.
///
/// The database will be stored in a file named `taskchampion-sync-server.sqlite3` in the given
/// directory.
pub fn new<P: AsRef<Path>>(directory: P) -> anyhow::Result<SqliteStorage> { pub fn new<P: AsRef<Path>>(directory: P) -> anyhow::Result<SqliteStorage> {
std::fs::create_dir_all(&directory) std::fs::create_dir_all(&directory)
.with_context(|| format!("Failed to create `{}`.", directory.as_ref().display()))?; .with_context(|| format!("Failed to create `{}`.", directory.as_ref().display()))?;