first commit
This commit is contained in:
commit
8c136cce33
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
18
Cargo.toml
Normal file
18
Cargo.toml
Normal 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
61
README.md
Normal 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
44
examples/demo.rs
Normal 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
236
src/lib.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user