first commit

This commit is contained in:
felixm 2024-12-07 13:20:02 -05:00
commit 8c136cce33
6 changed files with 1956 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

1596
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

18
Cargo.toml Normal file
View File

@ -0,0 +1,18 @@
[package]
name = "focusmate-rs"
version = "0.1.0"
edition = "2021"
description = "Rust client for the Focusmate API"
license = "MIT"
repository = "https://git.felixm.de/felixm/focusmate-rs"
[lib]
name = "focusmate"
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"] }

61
README.md Normal file
View File

@ -0,0 +1,61 @@
# focusmate-rs
Rust client library for the Focusmate API.
## Installation
Add to your Cargo.toml:
```toml
[dependencies]
focusmate-rs = "0.1.0"
```
## Usage
```rust
use focusmate::FocusmateClient;
use time::OffsetDateTime;
#[tokio::main]
async fn main() {
let client = FocusmateClient::new("your-api-key".to_string());
// Get profile
match client.get_me().await {
Ok(user) => println!("{user:#?}"),
Err(e) => println!("Error: {e:?}"),
}
// Get public profile of a user
match client
.get_user("6c267455-a530-4d4c-ba17-2e375932d976")
.await
{
Ok(user) => println!("{user:#?}"),
Err(e) => println!("Error: {e:?}"),
}
// Get sessions for last month
let start = OffsetDateTime::now_utc() - time::Duration::days(30);
let end = OffsetDateTime::now_utc();
let sessions = client.get_sessions(&start, &end).await;
match sessions {
Ok(sessions) => {
let n_sessions = sessions.iter().filter(|s| s.completed()).count();
println!("{n_sessions} completed sessions in period.");
}
Err(e) => println!("Error: {e:?}"),
}
}
```
## Features
- User profile lookup
- Session history with automatic year-chunking for long date ranges
- Session partner lookup
## License
MIT

44
examples/demo.rs Normal file
View File

@ -0,0 +1,44 @@
use focusmate::FocusmateClient;
use std::env;
use time::OffsetDateTime;
#[tokio::main]
async fn main() {
let api_key = match env::var("FOCUSMATE_API_KEY") {
Ok(key) => key,
Err(_) => {
eprintln!("Please set FOCUSMATE_API_KEY environment variable");
eprintln!("Example: FOCUSMATE_API_KEY=your-key cargo run --example get_sessions");
std::process::exit(1);
}
};
let client = FocusmateClient::new(api_key);
// Get profile
match client.get_me().await {
Ok(user) => println!("{user:#?}"),
Err(e) => eprintln!("Error: {e:?}"),
}
// Get public profile of a user
match client
.get_user("6c267455-a530-4d4c-ba17-2e375932d976")
.await
{
Ok(user) => println!("{user:#?}"),
Err(e) => eprintln!("Error: {e:?}"),
}
// Get sessions for last month
let start = OffsetDateTime::now_utc() - time::Duration::days(30);
let end = OffsetDateTime::now_utc();
let sessions = client.get_sessions(&start, &end).await;
match sessions {
Ok(sessions) => {
let n_sessions = sessions.iter().filter(|s| s.completed()).count();
println!("{n_sessions} completed sessions in period.");
}
Err(e) => eprintln!("Error: {e:?}"),
}
}

236
src/lib.rs Normal file
View File

@ -0,0 +1,236 @@
use reqwest::Client;
use serde::Deserialize;
use time::OffsetDateTime;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Time format error: {0}")]
TimeFormat(#[from] time::error::Format),
#[error("Session does not have at least 2 users")]
NoSessionPartner,
#[error("Invalid time range: {0}")]
InvalidTimeRange(String),
}
pub struct FocusmateClient {
client: Client,
api_key: String,
base_url: String,
}
#[derive(Debug, Deserialize)]
pub struct ProfileResponse {
pub user: User,
}
#[derive(Debug, Deserialize)]
pub struct PartnerProfileResponse {
pub user: PublicUser,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct User {
pub email: String,
#[serde(rename = "userId")]
pub user_id: String,
pub name: String,
#[serde(rename = "totalSessionCount")]
pub total_session_count: u32,
#[serde(rename = "timeZone")]
pub timezone: String,
#[serde(rename = "photoUrl")]
pub photo_url: String,
#[serde(rename = "memberSince")]
#[serde(with = "time::serde::rfc3339")]
pub member_since: OffsetDateTime,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicUser {
#[serde(rename = "userId")]
pub user_id: String,
pub name: String,
#[serde(rename = "totalSessionCount")]
pub total_session_count: u32,
#[serde(rename = "timeZone")]
pub timezone: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionUser {
#[serde(rename = "userId")]
pub user_id: String,
#[serde(rename = "sessionTitle", default)]
pub session_title: Option<String>,
#[serde(rename = "requestedAt", default)]
#[serde(with = "time::serde::rfc3339::option")]
pub requested_at: Option<OffsetDateTime>,
#[serde(rename = "joinedAt", default)]
#[serde(with = "time::serde::rfc3339::option")]
pub joined_at: Option<OffsetDateTime>,
#[serde(default)]
pub completed: bool,
#[serde(rename = "activityType", default)]
pub activity_type: Option<String>, // Could be an enum if we know all possible values
#[serde(default)]
pub preferences: Option<SessionPreferences>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionResponse {
pub sessions: Vec<Session>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Session {
#[serde(rename = "sessionId")]
pub session_id: String,
pub duration: u32,
#[serde(rename = "startTime")]
#[serde(with = "time::serde::rfc3339")]
pub start_time: OffsetDateTime,
pub users: Vec<SessionUser>,
}
impl Session {
/// Gets the partner's profile for this session.
///
/// # Errors
///
/// Returns an error if:
/// - The session doesn't have at least two users
/// - The API request to get the partner's profile fails
pub async fn get_partner_profile(&self, client: &FocusmateClient) -> Result<PublicUser, Error> {
if self.users.len() < 2 {
return Err(Error::NoSessionPartner);
}
client.get_user(&self.users[1].user_id).await
}
/// Returns whether the session was completed.
#[must_use]
pub fn completed(&self) -> bool {
!self.users.is_empty() && self.users[0].completed
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SessionPreferences {
#[serde(rename = "quietMode")]
pub quiet_mode: bool,
#[serde(rename = "preferFavorites")]
pub prefer_favorites: String,
}
impl FocusmateClient {
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))
.header("X-API-KEY", &self.api_key)
.send()
.await?
.error_for_status()?;
response.json().await.map_err(Error::from)
}
/// Creates a new `FocusmateClient` with the given API key.
#[must_use]
pub fn new(api_key: String) -> Self {
Self {
client: Client::new(),
api_key,
base_url: "https://api.focusmate.com/v1".to_string(),
}
}
/// Retrieves the current user's profile.
///
/// # Errors
///
/// Returns an error if:
/// - The API request fails
/// - Response parsing fails
pub async fn get_me(&self) -> Result<User, Error> {
self.request::<ProfileResponse>("/me").await.map(|r| r.user)
}
/// Retrieves the current user's profile.
///
/// # Errors
///
/// Returns an error if:
/// - The API request fails
/// - Response parsing fails
pub async fn get_user(&self, user_id: &str) -> Result<PublicUser, Error> {
self.request::<PartnerProfileResponse>(&format!("/users/{user_id}"))
.await
.map(|r| r.user)
}
/// Retrieves sessions between the given start and end times.
///
/// If the time range exceeds one year, it will automatically split into multiple requests.
/// Sessions are returned in reverse chronological order (newest first).
///
/// # Errors
///
/// Returns an error if:
/// - The start time is after the end time
/// - The API request fails
/// - Time formatting fails
pub async fn get_sessions(
&self,
start: &OffsetDateTime,
end: &OffsetDateTime,
) -> Result<Vec<Session>, Error> {
if start > end {
return Err(Error::InvalidTimeRange(
"start must be before end".to_string(),
));
}
let one_year = time::Duration::days(365);
let mut all_sessions = Vec::new();
let mut chunk_start = *start;
while chunk_start < *end {
let chunk_end = (*end).min(chunk_start + one_year);
let chunk_sessions = self.get_sessions_chunk(&chunk_start, &chunk_end).await?;
all_sessions.extend(chunk_sessions);
chunk_start = chunk_end;
}
all_sessions.sort_by(|a, b| b.start_time.cmp(&a.start_time));
Ok(all_sessions)
}
async fn get_sessions_chunk(
&self,
start: &OffsetDateTime,
end: &OffsetDateTime,
) -> Result<Vec<Session>, Error> {
let start_str = start.format(&time::format_description::well_known::Rfc3339)?;
let end_str = end.format(&time::format_description::well_known::Rfc3339)?;
let endpoint = format!("/sessions?start={start_str}&end={end_str}");
self.request::<SessionResponse>(&endpoint)
.await
.map(|r| r.sessions)
}
}