Initial commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1596
Cargo.lock
generated
Normal file
1596
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "beeminder-rs"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "beeminder"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
reqwest = { version = "^0.12", features = ["json"] }
|
||||||
|
serde = { version = "^1.0", features = ["derive"] }
|
||||||
|
thiserror = "^2.0"
|
||||||
|
tokio = { version = "^1.42", features = ["full"] }
|
||||||
|
time = { version = "^0.3", features = ["serde", "parsing", "formatting", "macros"] }
|
||||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# beeminder-rs
|
||||||
|
|
||||||
|
A Rust client library for the [Beeminder](https://www.beeminder.com/) API.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Add to your `Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
beeminder-rs = "0.1.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use beeminder::{BeeminderClient, types::CreateDatapoint};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let client = BeeminderClient::new(std::env::var("BEEMINDER_API_KEY")?);
|
||||||
|
|
||||||
|
// Create a datapoint
|
||||||
|
let datapoint = CreateDatapoint::new(42.0)
|
||||||
|
.with_timestamp(OffsetDateTime::now_utc())
|
||||||
|
.with_comment("Meditation session");
|
||||||
|
|
||||||
|
client.create_datapoint("username", "meditation", &datapoint).await?;
|
||||||
|
|
||||||
|
// Fetch recent datapoints
|
||||||
|
let datapoints = client
|
||||||
|
.get_datapoints("username", "meditation", Some("timestamp"), Some(10))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Valid Beeminder API key
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Pull requests welcome!
|
||||||
38
examples/demo.rs
Normal file
38
examples/demo.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use beeminder::types::CreateDatapoint;
|
||||||
|
use beeminder::BeeminderClient;
|
||||||
|
use std::env;
|
||||||
|
use time::macros::datetime;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let api_key =
|
||||||
|
env::var("BEEMINDER_API_KEY").expect("BEEMINDER_API_KEY environment variable not set");
|
||||||
|
|
||||||
|
let client = BeeminderClient::new(api_key);
|
||||||
|
match client.get_user("me").await {
|
||||||
|
Ok(user) => println!("{user:#?}"),
|
||||||
|
Err(e) => println!("{e:#?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let since = datetime!(2024-12-13 20:00 UTC);
|
||||||
|
match client.get_user_diff("me", since).await {
|
||||||
|
Ok(user) => println!("{user:#?}"),
|
||||||
|
Err(e) => println!("{e:#?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
match client
|
||||||
|
.get_datapoints("me", "meditation", None, Some(10))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(datapoints) => println!("{datapoints:#?}"),
|
||||||
|
Err(e) => println!("{e:#?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let d = CreateDatapoint::new(1.0)
|
||||||
|
.with_comment("Test datapoint")
|
||||||
|
.with_requestid("unique-id-42");
|
||||||
|
match client.create_datapoint("me", "meditation", &d).await {
|
||||||
|
Ok(datapoint) => println!("Added: {datapoint:#?}"),
|
||||||
|
Err(e) => println!("{e:#?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/lib.rs
Normal file
142
src/lib.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
pub mod types;
|
||||||
|
use crate::types::{CreateDatapoint, Datapoint, UserInfo, UserInfoDiff};
|
||||||
|
use reqwest::Client;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum Error {
|
||||||
|
#[error("HTTP error: {0}")]
|
||||||
|
Http(#[from] reqwest::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BeeminderClient {
|
||||||
|
client: Client,
|
||||||
|
api_key: String,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BeeminderClient {
|
||||||
|
async fn request<T>(&self, endpoint: &str) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, endpoint))
|
||||||
|
.query(&[("auth_token", &self.api_key)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
response.json().await.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post<T>(&self, endpoint: &str) -> Result<T, Error>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}{}", self.base_url, endpoint))
|
||||||
|
.query(&[("auth_token", &self.api_key)])
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?;
|
||||||
|
|
||||||
|
response.json().await.map_err(Error::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new `BeeminderClient` with the given API key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(api_key: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Client::new(),
|
||||||
|
api_key,
|
||||||
|
base_url: "https://www.beeminder.com/api/v1/".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves information about a user.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the HTTP request fails or response cannot be parsed.
|
||||||
|
pub async fn get_user(&self, username: &str) -> Result<UserInfo, Error> {
|
||||||
|
self.request(&format!("users/{username}.json")).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves detailed user information with changes since the specified timestamp.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the HTTP request fails or response cannot be parsed.
|
||||||
|
pub async fn get_user_diff(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
diff_since: OffsetDateTime,
|
||||||
|
) -> Result<UserInfoDiff, Error> {
|
||||||
|
let timestamp = diff_since.unix_timestamp();
|
||||||
|
self.request(&format!("users/{username}.json?diff_since={timestamp}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves datapoints for a specific goal.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the HTTP request fails or response cannot be parsed.
|
||||||
|
pub async fn get_datapoints(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
goal: &str,
|
||||||
|
sort: Option<&str>,
|
||||||
|
count: Option<u64>,
|
||||||
|
) -> Result<Vec<Datapoint>, Error> {
|
||||||
|
let mut endpoint = format!("users/{username}/goals/{goal}/datapoints.json");
|
||||||
|
|
||||||
|
let mut query = Vec::new();
|
||||||
|
|
||||||
|
if let Some(sort) = sort {
|
||||||
|
query.push(format!("sort={sort}"));
|
||||||
|
} else {
|
||||||
|
query.push("sort=timestamp".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(count) = count {
|
||||||
|
query.push(format!("count={count}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !query.is_empty() {
|
||||||
|
endpoint = format!("{}?{}", endpoint, query.join("&"));
|
||||||
|
}
|
||||||
|
|
||||||
|
self.request(&endpoint).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new datapoint for a goal.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Returns an error if the HTTP request fails or response cannot be parsed.
|
||||||
|
pub async fn create_datapoint(
|
||||||
|
&self,
|
||||||
|
username: &str,
|
||||||
|
goal: &str,
|
||||||
|
datapoint: &CreateDatapoint,
|
||||||
|
) -> Result<Datapoint, Error> {
|
||||||
|
let mut query = Vec::new();
|
||||||
|
query.push(format!("value={}", datapoint.value));
|
||||||
|
if let Some(ts) = datapoint.timestamp {
|
||||||
|
query.push(format!("timestamp={}", ts.unix_timestamp()));
|
||||||
|
}
|
||||||
|
if let Some(ds) = &datapoint.daystamp {
|
||||||
|
query.push(format!("daystamp={ds}"));
|
||||||
|
}
|
||||||
|
if let Some(c) = &datapoint.comment {
|
||||||
|
query.push(format!("comment={c}"));
|
||||||
|
}
|
||||||
|
if let Some(rid) = &datapoint.requestid {
|
||||||
|
query.push(format!("requestid={rid}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut endpoint = format!("users/{username}/goals/{goal}/datapoints.json");
|
||||||
|
endpoint = format!("{}?{}", endpoint, query.join("&"));
|
||||||
|
self.post(&endpoint).await
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/types.rs
Normal file
166
src/types.rs
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Goal {
|
||||||
|
/// Unique identifier as hex string, useful when slugs change
|
||||||
|
pub id: String,
|
||||||
|
/// Final part of goal URL, used as identifier (e.g., "weight" in beeminder.com/alice/weight)
|
||||||
|
pub slug: String,
|
||||||
|
/// User-specified title for the goal
|
||||||
|
pub title: String,
|
||||||
|
/// List of datapoints for this goal
|
||||||
|
pub datapoints: Vec<Datapoint>,
|
||||||
|
/// Summary of what needs to be done by when, e.g., "+2 within 1 day".
|
||||||
|
pub limsum: String,
|
||||||
|
/// Unix timestamp of the last time this goal was updated
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
/// User-provided description of what exactly they are committing to
|
||||||
|
pub fineprint: Option<String>,
|
||||||
|
/// Label for the y-axis of the graph
|
||||||
|
pub yaxis: String,
|
||||||
|
/// Unix timestamp of the goal date
|
||||||
|
#[serde(with = "time::serde::timestamp::option")]
|
||||||
|
pub goaldate: Option<OffsetDateTime>,
|
||||||
|
/// Goal value - the number the bright red line will eventually reach
|
||||||
|
pub goalval: Option<f64>,
|
||||||
|
/// Slope of the (final section of the) bright red line, paired with runits
|
||||||
|
pub rate: Option<f64>,
|
||||||
|
/// Rate units: y/m/w/d/h for yearly/monthly/weekly/daily/hourly
|
||||||
|
pub runits: String,
|
||||||
|
/// URL for the goal's graph SVG
|
||||||
|
pub svg_url: String,
|
||||||
|
/// URL for the goal's graph image
|
||||||
|
pub graph_url: String,
|
||||||
|
/// URL for the goal's graph thumbnail image
|
||||||
|
pub thumb_url: String,
|
||||||
|
/// Name of automatic data source, null for manual goals
|
||||||
|
pub autodata: Option<String>,
|
||||||
|
/// Type of goal (hustler/biker/fatloser/gainer/inboxer/drinker/custom)
|
||||||
|
pub goal_type: String,
|
||||||
|
/// Unix timestamp of derailment if nothing is reported
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub losedate: OffsetDateTime,
|
||||||
|
/// Key for sorting goals by decreasing urgency
|
||||||
|
pub urgencykey: String,
|
||||||
|
/// Whether the graph is currently being updated
|
||||||
|
pub queued: bool,
|
||||||
|
/// Whether goal requires login to view
|
||||||
|
pub secret: bool,
|
||||||
|
/// Whether datapoints require login to view
|
||||||
|
pub datapublic: bool,
|
||||||
|
/// Amount pledged in USD on the goal
|
||||||
|
pub pledge: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Datapoint {
|
||||||
|
/// A unique ID, used to identify a datapoint when deleting or editing it
|
||||||
|
pub id: String,
|
||||||
|
/// Unix timestamp (in seconds) of the datapoint
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub timestamp: OffsetDateTime,
|
||||||
|
/// Date of the datapoint (e.g., "20150831"), accounts for goal deadlines
|
||||||
|
pub daystamp: String,
|
||||||
|
/// The value measured at this datapoint
|
||||||
|
pub value: f64,
|
||||||
|
/// Optional comment about the datapoint
|
||||||
|
pub comment: Option<String>,
|
||||||
|
/// Unix timestamp when this datapoint was entered or last updated
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
/// Echo of API request ID if provided during creation
|
||||||
|
pub requestid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for creating or updating a datapoint
|
||||||
|
#[must_use]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CreateDatapoint {
|
||||||
|
/// The value to record
|
||||||
|
pub value: f64,
|
||||||
|
/// Timestamp for the datapoint, defaults to now if None
|
||||||
|
pub timestamp: Option<OffsetDateTime>,
|
||||||
|
/// Date string (e.g. "20150831"), alternative to timestamp
|
||||||
|
pub daystamp: Option<String>,
|
||||||
|
/// Optional comment
|
||||||
|
pub comment: Option<String>,
|
||||||
|
/// Optional unique identifier for deduplication/updates
|
||||||
|
pub requestid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateDatapoint {
|
||||||
|
/// Creates a new datapoint with just a value, all other fields None
|
||||||
|
pub fn new(value: f64) -> Self {
|
||||||
|
Self {
|
||||||
|
value,
|
||||||
|
timestamp: None,
|
||||||
|
daystamp: None,
|
||||||
|
comment: None,
|
||||||
|
requestid: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a timestamp
|
||||||
|
pub fn with_timestamp(mut self, timestamp: OffsetDateTime) -> Self {
|
||||||
|
self.timestamp = Some(timestamp);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a daystamp
|
||||||
|
pub fn with_daystamp(mut self, daystamp: &str) -> Self {
|
||||||
|
self.daystamp = Some(daystamp.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a comment
|
||||||
|
pub fn with_comment(mut self, comment: &str) -> Self {
|
||||||
|
self.comment = Some(comment.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a request ID
|
||||||
|
pub fn with_requestid(mut self, requestid: &str) -> Self {
|
||||||
|
self.requestid = Some(requestid.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfo {
|
||||||
|
/// Username of the Beeminder account
|
||||||
|
pub username: String,
|
||||||
|
/// User's timezone, e.g. "America/Los_Angeles"
|
||||||
|
pub timezone: String,
|
||||||
|
/// Timestamp when this user's information was last updated
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
/// Current urgency load (priority level of pending tasks)
|
||||||
|
pub urgency_load: u64,
|
||||||
|
/// Whether the user has an unpaid subscription
|
||||||
|
pub deadbeat: bool,
|
||||||
|
/// List of the user's goal slugs
|
||||||
|
pub goals: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserInfoDiff {
|
||||||
|
/// Username of the Beeminder account
|
||||||
|
pub username: String,
|
||||||
|
/// User's timezone, e.g. "America/Los_Angeles"
|
||||||
|
pub timezone: String,
|
||||||
|
/// Timestamp when this user's information was last updated
|
||||||
|
#[serde(with = "time::serde::timestamp")]
|
||||||
|
pub updated_at: OffsetDateTime,
|
||||||
|
/// List of user's goals with detailed information and datapoints
|
||||||
|
pub goals: Vec<Goal>,
|
||||||
|
/// List of goals that have been deleted since the diff timestamp
|
||||||
|
pub deleted_goals: Vec<DeletedGoal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DeletedGoal {
|
||||||
|
/// ID of the deleted goal
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user