Merge branch 'feature/api_structs' into develop

This commit is contained in:
Jörn-Michael Miehe 2025-06-27 08:30:18 +00:00
commit c10d86ff65
14 changed files with 289 additions and 173 deletions

42
.vscode/tasks.json vendored
View file

@ -43,16 +43,16 @@
"problemMatcher": "$rustc", "problemMatcher": "$rustc",
"group": "build" "group": "build"
}, },
// { {
// "label": "Run Unit Tests", "label": "Run Unit Tests",
// "type": "cargo", "type": "cargo",
// "command": "test", "command": "test",
// "args": [ "args": [
// "--lib" "--lib"
// ], ],
// "problemMatcher": "$rustc", "problemMatcher": "$rustc",
// "group": "test" "group": "test"
// }, },
// { // {
// "label": "Run Integration Tests", // "label": "Run Integration Tests",
// "type": "cargo", // "type": "cargo",
@ -64,16 +64,16 @@
// "problemMatcher": "$rustc", // "problemMatcher": "$rustc",
// "group": "test" // "group": "test"
// }, // },
// { {
// "label": "Run All Tests", "label": "Run All Tests",
// "type": "shell", "type": "shell",
// "command": "echo All Tests successful!", "command": "echo All Tests successful!",
// "dependsOn": [ "dependsOn": [
// "Run Unit Tests", "Run Unit Tests",
// "Run Integration Tests" "Run Integration Tests"
// ], ],
// "dependsOrder": "sequence", "dependsOrder": "sequence",
// "group": "test" "group": "test"
// } }
], ],
} }

View file

@ -8,7 +8,7 @@ use crate::{
cli::Cli, cli::Cli,
file::{Chunk, FileTrait}, file::{Chunk, FileTrait},
output::new_progressbar, output::new_progressbar,
sharry::Client, sharry::{Client, ShareID},
}; };
pub struct AppState { pub struct AppState {
@ -32,7 +32,7 @@ fn new_http(args: &Cli) -> ureq::Agent {
.into() .into()
} }
fn new_share(args: &Cli) -> crate::Result<String> { fn new_share(args: &Cli) -> crate::Result<ShareID> {
new_http(args).share_create(&args.get_uri(), &args.alias, args.get_share_request()) new_http(args).share_create(&args.get_uri(), &args.alias, args.get_share_request())
} }

View file

@ -14,7 +14,7 @@ use crate::{
cli::Cli, cli::Cli,
file::{self, Chunk, FileTrait}, file::{self, Chunk, FileTrait},
output::new_progressbar, output::new_progressbar,
sharry::{Client, Uri}, sharry::{AliasID, Client, ShareID, Uri},
}; };
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
@ -23,8 +23,8 @@ pub struct CacheFile {
file_name: PathBuf, file_name: PathBuf,
uri: Uri, uri: Uri,
alias_id: String, alias_id: AliasID,
share_id: String, share_id: ShareID,
uploading: Option<file::Uploading>, uploading: Option<file::Uploading>,
files: VecDeque<file::Checked>, files: VecDeque<file::Checked>,
@ -97,7 +97,7 @@ impl CacheFile {
pub fn from_args( pub fn from_args(
args: &Cli, args: &Cli,
new_share: impl FnOnce(&Cli) -> crate::Result<String>, new_share: impl FnOnce(&Cli) -> crate::Result<ShareID>,
) -> crate::Result<Self> { ) -> crate::Result<Self> {
let mut files = args.files.clone(); let mut files = args.files.clone();

View file

@ -11,7 +11,7 @@ use log::LevelFilter;
use crate::{ use crate::{
file::{Checked, FileTrait}, file::{Checked, FileTrait},
sharry::{NewShareRequest, Uri}, sharry::{AliasID, NewShareRequest, Uri},
}; };
#[derive(Parser)] #[derive(Parser)]
@ -69,7 +69,7 @@ pub struct Cli {
url: String, url: String,
/// ID of a public alias to use /// ID of a public alias to use
pub alias: String, pub alias: AliasID,
/// Files to upload to the new share /// Files to upload to the new share
#[arg(value_name = "FILE", required = true, value_parser = parse_sharry_file)] #[arg(value_name = "FILE", required = true, value_parser = parse_sharry_file)]
@ -159,7 +159,7 @@ impl Cli {
let mut hasher = Blake2b::new().hash_length(16).to_state(); let mut hasher = Blake2b::new().hash_length(16).to_state();
hasher.update(self.get_uri().as_ref()); hasher.update(self.get_uri().as_ref());
hasher.update(self.alias.as_bytes()); hasher.update(self.alias.as_ref());
for chk in sorted(&self.files) { for chk in sorted(&self.files) {
hasher.update(chk.as_ref()); hasher.update(chk.as_ref());

View file

@ -1,18 +1,20 @@
use std::fmt; use std::fmt;
use crate::sharry;
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum Parameter { pub enum Parameter {
#[error("given URI {0:?}")] #[error("given URI {0:?}")]
Uri(String), Uri(String),
#[error("given Alias ID {0:?}")] #[error("given Alias ID {0:?}")]
AliasID(String), AliasID(sharry::AliasID),
#[error("stored Share ID {0:?}")] #[error("stored Share ID {0:?}")]
ShareID(String), ShareID(sharry::ShareID),
#[error("stored File ID {0:?}")] #[error("stored {0:?}")]
FileID(String), FileID(sharry::FileID),
} }
impl Parameter { impl Parameter {

View file

@ -76,8 +76,8 @@ impl Checked {
self, self,
client: &impl sharry::Client, client: &impl sharry::Client,
uri: &sharry::Uri, uri: &sharry::Uri,
alias_id: &str, alias_id: &sharry::AliasID,
share_id: &str, share_id: &sharry::ShareID,
) -> crate::Result<Uploading> { ) -> crate::Result<Uploading> {
let file_id = client.file_create(uri, alias_id, share_id, &self)?; let file_id = client.file_create(uri, alias_id, share_id, &self)?;

View file

@ -1,7 +1,9 @@
use std::fmt; use std::fmt;
use crate::sharry;
pub struct Chunk<'t> { pub struct Chunk<'t> {
file_id: String, file_id: sharry::FileID,
offset: u64, offset: u64,
data: &'t [u8], data: &'t [u8],
} }
@ -17,7 +19,7 @@ impl fmt::Debug for Chunk<'_> {
} }
impl<'t> Chunk<'t> { impl<'t> Chunk<'t> {
pub fn new(file_id: String, offset: u64, data: &'t [u8]) -> Self { pub fn new(file_id: sharry::FileID, offset: u64, data: &'t [u8]) -> Self {
Self { Self {
file_id, file_id,
offset, offset,
@ -25,7 +27,7 @@ impl<'t> Chunk<'t> {
} }
} }
pub fn get_file_id(&self) -> &str { pub fn get_file_id(&self) -> &sharry::FileID {
&self.file_id &self.file_id
} }

View file

@ -7,6 +7,7 @@ use std::{
use log::warn; use log::warn;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::sharry;
use super::{Checked, Chunk, FileTrait}; use super::{Checked, Chunk, FileTrait};
@ -18,14 +19,19 @@ pub struct Uploading {
size: u64, size: u64,
/// hash of that file /// hash of that file
hash: Option<String>, hash: Option<String>,
file_id: String, file_id: sharry::FileID,
#[serde(skip)] #[serde(skip)]
last_offset: Option<u64>, last_offset: Option<u64>,
offset: u64, offset: u64,
} }
impl Uploading { impl Uploading {
pub(super) fn new(path: PathBuf, size: u64, hash: Option<String>, file_id: String) -> Self { pub(super) fn new(
path: PathBuf,
size: u64,
hash: Option<String>,
file_id: sharry::FileID,
) -> Self {
Self { Self {
path, path,
size, size,

View file

@ -2,14 +2,14 @@ use log::{debug, trace};
use crate::{ use crate::{
file::{self, FileTrait}, file::{self, FileTrait},
sharry::{self, Uri}, sharry::{self, AliasID, FileID, ShareID, Uri},
}; };
fn find_cause( fn find_cause(
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
share_id: Option<&str>, share_id: Option<&ShareID>,
file_id: Option<&str>, file_id: Option<&FileID>,
) -> impl FnOnce(ureq::Error) -> crate::Error { ) -> impl FnOnce(ureq::Error) -> crate::Error {
move |error| match error { move |error| match error {
ureq::Error::StatusCode(403) => { ureq::Error::StatusCode(403) => {
@ -49,15 +49,15 @@ impl sharry::Client for ureq::Agent {
fn share_create( fn share_create(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
data: sharry::NewShareRequest, data: sharry::NewShareRequest,
) -> crate::Result<String> { ) -> crate::Result<ShareID> {
let res = { let res = {
let endpoint = uri.share_create(); let endpoint = uri.share_create();
let mut res = self let mut res = self
.post(&endpoint) .post(&endpoint)
.header("Sharry-Alias", alias_id) .header("Sharry-Alias", alias_id.as_ref())
.send_json(data) .send_json(data)
.map_err(find_cause(uri, alias_id, None, None))?; .map_err(find_cause(uri, alias_id, None, None))?;
@ -74,19 +74,19 @@ impl sharry::Client for ureq::Agent {
if res.success && (res.message == "Share created.") { if res.success && (res.message == "Share created.") {
trace!("new share id: {:?}", res.id); trace!("new share id: {:?}", res.id);
Ok(res.id) Ok(res.id.into())
} else { } else {
Err(crate::Error::response(format!("{res:?}"))) Err(crate::Error::response(format!("{res:?}")))
} }
} }
fn share_notify(&self, uri: &Uri, alias_id: &str, share_id: &str) -> crate::Result<()> { fn share_notify(&self, uri: &Uri, alias_id: &AliasID, share_id: &ShareID) -> crate::Result<()> {
let res = { let res = {
let endpoint = uri.share_notify(share_id); let endpoint = uri.share_notify(share_id);
let mut res = self let mut res = self
.post(&endpoint) .post(&endpoint)
.header("Sharry-Alias", alias_id) .header("Sharry-Alias", alias_id.as_ref())
.send_empty() .send_empty()
.map_err(find_cause(uri, alias_id, Some(share_id), None))?; .map_err(find_cause(uri, alias_id, Some(share_id), None))?;
@ -106,16 +106,16 @@ impl sharry::Client for ureq::Agent {
fn file_create( fn file_create(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
share_id: &str, share_id: &ShareID,
file: &file::Checked, file: &file::Checked,
) -> crate::Result<String> { ) -> crate::Result<FileID> {
let res = { let res = {
let endpoint = uri.file_create(share_id); let endpoint = uri.file_create(share_id);
let res = self let res = self
.post(&endpoint) .post(&endpoint)
.header("Sharry-Alias", alias_id) .header("Sharry-Alias", alias_id.as_ref())
.header("Sharry-File-Name", file.get_name()) .header("Sharry-File-Name", file.get_name())
.header("Upload-Length", file.get_size()) .header("Upload-Length", file.get_size())
.send_empty() .send_empty()
@ -132,18 +132,14 @@ impl sharry::Client for ureq::Agent {
.map_err(crate::Error::response)? .map_err(crate::Error::response)?
.to_string(); .to_string();
let file_id = Self::get_file_id(&location)?; FileID::try_from(location)
debug!("location: {location:?}, file_id: {file_id:?}");
Ok(file_id.to_owned())
} }
fn file_patch( fn file_patch(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
share_id: &str, share_id: &ShareID,
chunk: &file::Chunk, chunk: &file::Chunk,
) -> crate::Result<()> { ) -> crate::Result<()> {
let res = { let res = {
@ -151,7 +147,7 @@ impl sharry::Client for ureq::Agent {
let res = self let res = self
.patch(&endpoint) .patch(&endpoint)
.header("Sharry-Alias", alias_id) .header("Sharry-Alias", alias_id.as_ref())
.header("Upload-Offset", chunk.get_offset()) .header("Upload-Offset", chunk.get_offset())
.send(chunk.get_data()) .send(chunk.get_data())
.map_err(find_cause( .map_err(find_cause(

155
src/sharry/api/ids.rs Normal file
View file

@ -0,0 +1,155 @@
use std::{fmt, sync::LazyLock};
use log::{debug, trace};
use regex::Regex;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct AliasID(String);
impl fmt::Display for AliasID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl AsRef<[u8]> for AliasID {
fn as_ref(&self) -> &[u8] {
self.0.as_bytes()
}
}
impl From<String> for AliasID {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ShareID(String);
impl fmt::Display for ShareID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for ShareID {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct FileID(String);
impl fmt::Display for FileID {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for FileID {
type Error = crate::Error;
fn try_from(value: String) -> crate::Result<Self> {
/// Pattern breakdown:
/// - `^([^:/?#]+)://` scheme (anything but `:/?#`) + `"://"`
/// - `([^/?#]+)` authority/host (anything but `/?#`)
/// - `/api/v2/alias/upload/` literal path segment
/// - `([^/]+)` capture SID (one or more non-slash chars)
/// - `/files/tus/` literal path segment
/// - `(?P<fid>[^/]+)` capture FID (one or more non-slash chars)
/// - `$` end of string
static UPLOAD_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
trace!("compiling UPLOAD_URL_RE");
Regex::new(
r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P<fid>[^/]+)$",
)
.expect("Regex compilation failed")
});
trace!("TryFrom {value:?}");
if let Some(fid) = UPLOAD_URL_RE
.captures(&value)
.and_then(|caps| caps.name("fid").map(|m| m.as_str()))
{
let result = Self(fid.to_owned());
debug!("{result:?}");
Ok(result)
} else {
Err(crate::Error::mismatch(
"<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
value,
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_urls_produce_expected_file_id() {
// a handful of validlooking URLs
let cases = vec![
(
"http://example.com/api/v2/alias/upload/SID123/files/tus/FID456",
"FID456",
),
(
"https://my-host:8080/api/v2/alias/upload/another-SID/files/tus/some-file-id",
"some-file-id",
),
(
"custom+scheme://host/api/v2/alias/upload/x/files/tus/y",
"y",
),
];
for (good, expected_fid) in cases {
let s = good.to_string();
let file_id = FileID::try_from(s.clone()).expect("URL should parse successfully");
assert_eq!(
file_id.0, expected_fid,
"Expected `{}` → FileID({}), got {:?}",
good, expected_fid, file_id
);
}
}
#[test]
fn invalid_urls_return_error() {
let bad_inputs = vec![
// missing /api/v2/alias/upload
"http://example.com/files/tus/FID",
// missing /files/tus
"http://example.com/api/v2/alias/upload/SID123/FID456",
// trailing slash (doesn't match `$`)
"http://example.com/api/v2/alias/upload/SID/files/tus/FID/",
// empty fid
"http://example.com/api/v2/alias/upload/SID/files/tus/",
// random string
"just-a-random-string",
];
for bad in bad_inputs {
let err = FileID::try_from(bad.to_string()).expect_err("URL should not parse");
// make sure it's the Mismatch variant, and that it contains the original input
match err {
crate::Error::Mismatch { expected, actual } => {
assert_eq!(
expected, "<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
"Error should output expected format"
);
assert_eq!(actual, bad.to_string(), "Error should echo back the input");
}
_ => panic!("Expected Error::Mismatch for input `{bad}` but got {err:?}"),
}
}
}
}

47
src/sharry/api/mod.rs Normal file
View file

@ -0,0 +1,47 @@
mod ids;
mod uri;
use serde::{Deserialize, Serialize};
pub use ids::{AliasID, FileID, ShareID};
pub use uri::Uri;
#[derive(Serialize, Debug)]
#[allow(non_snake_case)]
pub struct NewShareRequest {
name: String,
validity: u32,
description: Option<String>,
maxViews: u32,
password: Option<String>,
}
impl NewShareRequest {
pub fn new(
name: impl Into<String>,
description: Option<impl Into<String>>,
max_views: u32,
) -> Self {
Self {
name: name.into(),
validity: 0,
description: description.map(Into::into),
maxViews: max_views,
password: None,
}
}
}
#[derive(Deserialize, Debug)]
pub struct NewShareResponse {
pub success: bool,
pub message: String,
pub id: String,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct NotifyShareResponse {
pub success: bool,
pub message: String,
}

View file

@ -33,55 +33,15 @@ impl Uri {
self.endpoint(format_args!("alias/upload/new")) self.endpoint(format_args!("alias/upload/new"))
} }
pub fn share_notify(&self, share_id: &str) -> String { pub fn share_notify(&self, share_id: &super::ShareID) -> String {
self.endpoint(format_args!("alias/mail/notify/{share_id}")) self.endpoint(format_args!("alias/mail/notify/{share_id}"))
} }
pub fn file_create(&self, share_id: &str) -> String { pub fn file_create(&self, share_id: &super::ShareID) -> String {
self.endpoint(format_args!("alias/upload/{share_id}/files/tus")) self.endpoint(format_args!("alias/upload/{share_id}/files/tus"))
} }
pub fn file_patch(&self, share_id: &str, file_id: &str) -> String { pub fn file_patch(&self, share_id: &super::ShareID, file_id: &super::FileID) -> String {
self.endpoint(format_args!("alias/upload/{share_id}/files/tus/{file_id}")) self.endpoint(format_args!("alias/upload/{share_id}/files/tus/{file_id}"))
} }
} }
#[derive(Serialize, Debug)]
#[allow(non_snake_case)]
pub struct NewShareRequest {
name: String,
validity: u32,
description: Option<String>,
maxViews: u32,
password: Option<String>,
}
impl NewShareRequest {
pub fn new(
name: impl Into<String>,
description: Option<impl Into<String>>,
max_views: u32,
) -> Self {
Self {
name: name.into(),
validity: 0,
description: description.map(Into::into),
maxViews: max_views,
password: None,
}
}
}
#[derive(Deserialize, Debug)]
pub struct NewShareResponse {
pub success: bool,
pub message: String,
pub id: String,
}
#[derive(Deserialize, Debug)]
#[allow(dead_code)]
pub struct NotifyShareResponse {
pub success: bool,
pub message: String,
}

View file

@ -1,87 +1,33 @@
use std::sync::LazyLock;
use log::trace;
use regex::Regex;
use crate::file; use crate::file;
use super::api::{NewShareRequest, Uri}; use super::{
AliasID, ShareID,
api::{FileID, NewShareRequest, Uri},
};
pub trait Client { pub trait Client {
fn get_file_id(uri: &str) -> crate::Result<&str> {
/// Pattern breakdown:
/// - `^([^:/?#]+)://` scheme (anything but `:/?#`) + `"://"`
/// - `([^/?#]+)` authority/host (anything but `/?#`)
/// - `/api/v2/alias/upload/` literal path segment
/// - `([^/]+)` capture SID (one or more non-slash chars)
/// - `/files/tus/` literal path segment
/// - `(?P<fid>[^/]+)` capture FID (one or more non-slash chars)
/// - `$` end of string
static UPLOAD_URL_RE: LazyLock<Regex> = LazyLock::new(|| {
trace!("compiling UPLOAD_URL_RE");
Regex::new(
r"^([^:/?#]+)://([^/?#]+)/api/v2/alias/upload/[^/]+/files/tus/(?P<fid>[^/]+)$",
)
.expect("Regex compilation failed")
});
if let Some(fid) = UPLOAD_URL_RE
.captures(uri)
.and_then(|caps| caps.name("fid").map(|m| m.as_str()))
{
Ok(fid)
} else {
Err(crate::Error::mismatch(
"<proto>://<base>/api/v2/alias/upload/<share>/files/tus/<file>",
uri,
))
}
}
fn share_create( fn share_create(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
data: NewShareRequest, data: NewShareRequest,
) -> crate::Result<String>; ) -> crate::Result<ShareID>;
fn share_notify(&self, uri: &Uri, alias_id: &str, share_id: &str) -> crate::Result<()>; fn share_notify(&self, uri: &Uri, alias_id: &AliasID, share_id: &ShareID) -> crate::Result<()>;
fn file_create( fn file_create(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
share_id: &str, share_id: &ShareID,
file: &file::Checked, file: &file::Checked,
) -> crate::Result<String>; ) -> crate::Result<FileID>;
fn file_patch( fn file_patch(
&self, &self,
uri: &Uri, uri: &Uri,
alias_id: &str, alias_id: &AliasID,
share_id: &str, share_id: &ShareID,
chunk: &file::Chunk, chunk: &file::Chunk,
) -> crate::Result<()>; ) -> crate::Result<()>;
} }
// TODO move into tests subdir
// #[cfg(test)]
// mod tests {
// use super::*;
// #[test]
// fn test_get_file_id() {
// let good = "https://example.com/api/v2/alias/upload/SID123/files/tus/FID456";
// let good = Client::get_file_id(good);
// assert!(good.is_ok());
// assert_eq!(good.unwrap(), "FID456");
// let bad = "https://example.com/api/v2/alias/upload//files/tus/FID456"; // missing SID
// assert!(Client::get_file_id(bad).is_err());
// let bad: &'static str = "https://example.com/api/v2/alias/upload/SID123/files/tus/"; // missing FID
// assert!(Client::get_file_id(bad).is_err());
// }
// }

View file

@ -1,5 +1,7 @@
mod api; mod api;
mod client; mod client;
pub use api::{NewShareRequest, NewShareResponse, NotifyShareResponse, Uri}; pub use api::{
AliasID, FileID, NewShareRequest, NewShareResponse, NotifyShareResponse, ShareID, Uri,
};
pub use client::Client; pub use client::Client;