Skip to content
This repository was archived by the owner on Mar 27, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
resolver = "2"
members = [
"crates/cli",
"crates/client",
"crates/model",
"crates/server",
"crates/shared",
]

[workspace.package]
Expand All @@ -25,6 +27,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0" }
shellexpand = { version = "3.1" }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio", "time", "uuid"] }
test-log = { version = "0.2", features = ["trace"] }
thiserror = { version = "2.0" }
time = { version = "0.3", features = ["formatting", "macros", "serde"] }
time-humanize = { version = "0.1" }
Expand All @@ -34,7 +37,9 @@ tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1.11", features = ["v4", "serde"] }

parcel-client = { path = "crates/client" }
parcel-model = { path = "crates/model" }
parcel-shared = { path = "crates/shared" }

[profile.release]
opt-level = 3
Expand Down
21 changes: 21 additions & 0 deletions crates/client/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
[package]
name = "parcel-client"
version.workspace = true
edition.workspace = true
publish = false

[dependencies]
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
time.workspace = true
uuid.workspace = true

parcel-shared.workspace = true

typed-builder = { version = "0.21" }

[dependencies.reqwest]
version = "0.12"
default-features = false
features = ["json", "rustls-tls-native-roots"]
220 changes: 220 additions & 0 deletions crates/client/src/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
use parcel_shared::types::api::{
ApiMeResponse, ApiTeamResponse, ApiTeamsResponse, ApiUploadOrder, ApiUploadResponse,
ApiUploadSort, ApiUploadsResponse,
};
use reqwest::{
header::{AUTHORIZATION, USER_AGENT},
Method,
};
use typed_builder::TypedBuilder;
use uuid::Uuid;

static PARCEL_USER_AGENT: &str = concat!("parcel/", env!("CARGO_PKG_VERSION"));

pub struct Client {
client: reqwest::Client,
base_url: String,
api_key: String,
}

impl Client {
pub fn build() -> ClientBuilder {
ClientBuilder::new()
}

pub fn new(base_url: String, api_key: String) -> Self {
let client = reqwest::Client::new();
Client {
client,
base_url,
api_key,
}
}

pub fn new_with_client(base_url: String, api_key: String, client: reqwest::Client) -> Self {
Client {
client,
base_url,
api_key,
}
}

pub fn get_api_key(&self) -> &str {
&self.api_key
}

pub fn get_base_url(&self) -> &str {
&self.base_url
}

pub fn get_client(&self) -> &reqwest::Client {
&self.client
}

pub fn request(&self, method: Method, path: &str) -> reqwest::RequestBuilder {
let url = format!("{}{}", self.base_url, path);
self.client
.request(method, &url)
.header(USER_AGENT, PARCEL_USER_AGENT)
.header(AUTHORIZATION, format!("Bearer {}", self.api_key))
}

pub async fn get_me(&self) -> Result<ApiMeResponse, ClientError> {
let response = self.request(Method::GET, "/api/1.0/me").send().await?;
let response = response.json().await?;
Ok(response)
}

pub async fn get_teams(&self) -> Result<ApiTeamsResponse, ClientError> {
let response = self.request(Method::GET, "/api/1.0/teams").send().await?;
let response = response.json().await?;
Ok(response)
}

pub async fn get_team(&self, team_id: Uuid) -> Result<ApiTeamResponse, ClientError> {
let response = self
.request(Method::GET, &format!("/api/1.0/teams/{team_id}"))
.send()
.await?;
let response = response.json().await?;
Ok(response)
}

pub async fn get_uploads(
&self,
query: UploadsListQuery,
) -> Result<ApiUploadsResponse, ClientError> {
let mut builder = self.request(Method::GET, "/api/1.0/uploads");
let params = query.into_params();

if !params.is_empty() {
builder = builder.query(&params);
}

let response = builder.send().await?;
let response = response.json().await?;

Ok(response)
}

pub async fn get_team_uploads(
&self,
team_id: Uuid,
query: UploadsListQuery,
) -> Result<ApiUploadsResponse, ClientError> {
let mut builder = self.request(Method::GET, &format!("/api/1.0/teams/{team_id}/uploads"));
let params = query.into_params();

if !params.is_empty() {
builder = builder.query(&params);
}

let response = builder.send().await?;
let response = response.json().await?;

Ok(response)
}

pub async fn get_upload(&self, upload_id: Uuid) -> Result<ApiUploadResponse, ClientError> {
let response = self
.request(Method::GET, &format!("/api/1.0/uploads/{upload_id}"))
.send()
.await?;
let response = response.json().await?;
Ok(response)
}
}

#[derive(Debug, Default, Clone, TypedBuilder)]
pub struct UploadsListQuery {
filename: Option<String>,
sort: Option<ApiUploadSort>,
order: Option<ApiUploadOrder>,
limit: Option<u32>,
offset: Option<u32>,
}

impl UploadsListQuery {
fn into_params(self) -> Vec<(&'static str, String)> {
let mut params = Vec::new();

if let Some(filename) = self.filename {
params.push(("filename", filename));
}
if let Some(sort) = self.sort {
params.push(("sort", sort.to_string()));
}
if let Some(order) = self.order {
params.push(("order", order.to_string()));
}
if let Some(limit) = self.limit {
params.push(("limit", limit.to_string()));
}
if let Some(offset) = self.offset {
params.push(("offset", offset.to_string()));
}

params
}
}

#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("Request failed: {0}")]
RequestFailed(#[from] reqwest::Error),
#[error("JSON deserialization error: {0}")]
JsonError(#[from] serde_json::Error),
#[error("API error: {0}")]
ApiError(String),
}

#[derive(Debug, Default)]
pub struct ClientBuilder {
client: Option<reqwest::Client>,
base_url: Option<String>,
api_key: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum ClientBuilderError {
#[error("Missing API key")]
MissingApiKey,
#[error("Missing base URL")]
MissingBaseUrl,
}

impl ClientBuilder {
pub fn new() -> Self {
ClientBuilder {
client: None,
base_url: None,
api_key: None,
}
}

pub fn with_client(mut self, client: reqwest::Client) -> Self {
self.client = Some(client);
self
}

pub fn with_base_url(mut self, base_url: String) -> Self {
self.base_url = Some(base_url);
self
}

pub fn with_api_key(mut self, api_key: String) -> Self {
self.api_key = Some(api_key);
self
}

pub fn build(self) -> Result<Client, ClientBuilderError> {
let client = self.client.unwrap_or_default();
let base_url = self.base_url.ok_or(ClientBuilderError::MissingBaseUrl)?;
let api_key = self.api_key.ok_or(ClientBuilderError::MissingApiKey)?;
Ok(Client {
client,
base_url,
api_key,
})
}
}
3 changes: 3 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pub mod client;

pub use client::{Client, ClientBuilder};
15 changes: 15 additions & 0 deletions crates/model/migrations/014_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Add API keys table and aassociations to users and teams.

CREATE TABLE api_keys (
id TEXT NOT NULL PRIMARY KEY,
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL,
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
last_used TIMESTAMP
);

-- Make sure that the code for an API key is unique.
CREATE UNIQUE INDEX api_keys_code_uindex ON api_keys (code);
83 changes: 83 additions & 0 deletions crates/model/src/api_key.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use serde::Serialize;
use sqlx::{FromRow, SqlitePool};
use time::OffsetDateTime;

use crate::{types::Key, user::User};

#[derive(Debug, FromRow, Serialize)]
pub struct ApiKey {
pub id: Key<ApiKey>,
pub owner: Key<User>,
#[serde(skip)]
pub code: String,
pub name: String,
pub enabled: bool,
pub created_at: OffsetDateTime,
pub created_by: Option<Key<User>>,
pub last_used: Option<OffsetDateTime>,
}

impl ApiKey {
pub fn new(owner: Key<User>, code: String, name: String, created_by: Key<User>) -> Self {
let id = Key::new();
Self {
id,
owner,
code,
name,
enabled: true,
created_at: OffsetDateTime::now_utc(),
created_by: Some(created_by),
last_used: None,
}
}

pub async fn create(&self, pool: &SqlitePool) -> sqlx::Result<()> {
sqlx::query(
"INSERT INTO api_keys \
(id, owner, code, name, enabled, created_at, created_by) \
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(self.id)
.bind(self.owner)
.bind(&self.code)
.bind(&self.name)
.bind(self.enabled)
.bind(self.created_at)
.bind(self.created_by)
.execute(pool)
.await?;
Ok(())
}

pub async fn get(pool: &SqlitePool, id: Key<ApiKey>) -> sqlx::Result<Option<Self>> {
sqlx::query_as("SELECT * FROM api_keys WHERE id = $1")
.bind(id)
.fetch_optional(pool)
.await
}

pub async fn get_by_code(pool: &SqlitePool, code: &str) -> sqlx::Result<Option<Self>> {
sqlx::query_as("SELECT * FROM api_keys WHERE code = $1")
.bind(code)
.fetch_optional(pool)
.await
}

pub async fn record_last_use(&mut self, pool: &SqlitePool) -> sqlx::Result<()> {
let now = OffsetDateTime::now_utc();

let result = sqlx::query("UPDATE api_keys SET last_used = $1 WHERE id = $2")
.bind(now)
.bind(self.id)
.execute(pool)
.await?;

if result.rows_affected() == 0 {
return Err(sqlx::Error::RowNotFound);
}

self.last_used = Some(now);
Ok(())
}
}
1 change: 1 addition & 0 deletions crates/model/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod api_key;
pub mod migration;
pub mod password;
pub mod team;
Expand Down
12 changes: 8 additions & 4 deletions crates/model/src/team.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,14 @@ impl Team {
}

pub async fn get_for_user(pool: &SqlitePool, user: Key<User>) -> sqlx::Result<Vec<Self>> {
sqlx::query_as("SELECT teams.* FROM teams LEFT JOIN team_members ON team_members.team = teams.id WHERE team_members.user = $1")
.bind(user)
.fetch_all(pool)
.await
sqlx::query_as(
"SELECT teams.* FROM teams \
LEFT JOIN team_members ON team_members.team = teams.id \
WHERE team_members.user = $1",
)
.bind(user)
.fetch_all(pool)
.await
}

pub async fn delete(&self, pool: &SqlitePool) -> sqlx::Result<()> {
Expand Down
Loading