first commit
This commit is contained in:
commit
7371f92d71
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
|
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