diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cbc8fc..6543bdf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,8 +14,8 @@ jobs: - uses: actions/checkout@v4 - run: rustup default ${{ matrix.channel }} - run: cargo build --verbose --all-targets - # not running integration tests - - run: cargo test --lib ucare -- --nocapture + # --lib already excludes the integration tests, they are separate targets + - run: cargo test --lib -- --nocapture clippy: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc2310..15bb106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 0.4.0 (Aug 11, 2026) + +IMPROVEMENTS: +* Support [API v0.7](https://uploadcare.com/docs/changelog/2026/6/29/) +* Add new REST capabilities: Add-Ons module, file search, file tags and metadata endpoints. +* Improve robustness: error body handling (incl. empty/non-JSON), Retry-After parsing, warning header logging, query value encoding, and Upload API schema fixes (e.g., from_url response tagging, u64 counters, nullable fields). + +BREAKING CHANGES: + +* `RestApiVersion::V05` and `V06` are gone, the client works with v0.7 only. +* **Subscriptions are now created with version `0.7` instead of `0.6`.**. Receivers written + against the `0.6` format have to be updated before upgrading, or they will break on + the first delivery. Subscriptions created earlier are **not** affected — they keep + their own version and their own delivery format, forever. +* Schema changes (review old contracts on upgrade) + ## 0.3.1 (Apr 16, 2026) IMPROVEMENTS: diff --git a/Cargo.lock b/Cargo.lock index 66a047a..a3817b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1365,7 +1365,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uploadcare" -version = "0.3.1" +version = "0.4.0" dependencies = [ "chrono", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 9c5ee30..b6e3d95 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "uploadcare" -version = "0.3.1" +version = "0.4.0" authors = ["yarikbratashchuk "] edition = "2018" license = "MIT" diff --git a/README.md b/README.md index 727b4dc..1b32fde 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ let creds = ucare::apicreds { // creating rest client let config = ucare::RestConfig { sign_based_auth: true, - api_version: ucare::RestApiVersion::v06, + api_version: ucare::RestApiVersion::V07, }; let rest_client = ucare::RestClient::new(config, creds).unwrap(); diff --git a/src/addons.rs b/src/addons.rs new file mode 100644 index 0000000..e8072e8 --- /dev/null +++ b/src/addons.rs @@ -0,0 +1,641 @@ +//! Holds all primitives and logic related to Add-Ons. +//! +//! Add-Ons are applications that process an already uploaded file: virus scanning, +//! object recognition, background removal and so on. Available since APIv0.7 only. +//! +//! Processing is asynchronous and takes two steps. [`Service::execute`] runs all the +//! checks synchronously, queues the job and returns a `request_id` right away — a +//! successful response means "accepted", not "finished". [`Service::status`] is then +//! polled with that `request_id` until the status becomes +//! [`Status::Done`] or [`Status::Error`]. +//! +//! [`Service::execute_and_wait`] wraps the two into a single call with an explicit +//! timeout, which is usually what you want. +//! +//! Where the actual output lands depends on the application. Most of them return no +//! `result` at all and write into the file properties instead, readable through +//! [`crate::file::Info::appdata`] — `{"status": "done"}` with no `result` is a normal +//! success, not a parsing problem. `remove_bg` is the exception: it creates a new +//! file and returns its id in `result`. + +use std::cmp::min; +use std::thread; +use std::time::{Duration, Instant}; + +use reqwest::Method; +use serde::{Deserialize, Serialize}; + +use crate::ucare::{ + encode_json, encode_path_segment, encode_query_value, rest::Client, Error, Result, +}; + +/// Delay before the first status poll. +const FIRST_POLL_DELAY: Duration = Duration::from_secs(1); +/// Upper bound for the status poll interval. +const MAX_POLL_DELAY: Duration = Duration::from_secs(5); +/// Consecutive status poll failures tolerated before [`Outcome::PollFailed`]. +const MAX_POLL_FAILURES: u32 = 3; +/// Consecutive `unknown` statuses tolerated before [`Outcome::Unknown`]. +/// +/// The status of a just accepted job is eventually consistent: the first poll, +/// a second after `execute` returned, can answer `unknown` for a job that is +/// very much alive. Reporting that right away invites a re-run of a job that is +/// not idempotent, so a few of them in a row are required. +const MAX_UNKNOWN_POLLS: u32 = 3; + +/// Service is used to make calls to the Add-Ons API. +pub struct Service<'a> { + client: &'a Client, +} + +/// creates an instance of the addons service +pub fn new_svc(client: &Client) -> Service<'_> { + Service { client } +} + +impl Service<'_> { + /// Starts processing a file by an application. + /// + /// `application_id` is a plain string on purpose: the set of applications grows + /// without an API version bump, so it is not modelled as an enum. Known values at + /// the time of writing are `uc_clamav_virus_scan`, `aws_rekognition_detect_labels`, + /// `aws_rekognition_detect_moderation_labels` and `remove_bg`. + /// + /// A successful call only means the job was accepted. Notable errors: + /// + /// - `ErrValue::Conflict` — the same application is already processing this file. + /// Treat it as "already running", not as "retry in a second": if the previous + /// run died without updating its status, the pair can stay locked until that + /// state expires (observed to take up to a day; not documented). + /// - `ErrValue::Forbidden` — the application is disabled for the project. + /// Permissions are per application, so this says nothing about the others. + /// - `ErrValue::NotFound` — unknown `application_id`, or the file is not in the + /// project. The two are indistinguishable by status code. + /// - `ErrValue::TooManyRequests` — the launch rate limit, which depends on the + /// project plan. + /// + /// Calls are not idempotent: every successful one starts a new job with a new + /// `request_id`, and for `remove_bg` that means another new file. Do not blindly + /// retry a call whose response was lost. + pub fn execute(&self, application_id: &str, params: ExecuteParams) -> Result { + let json = encode_json(¶ms)?; + + self.client.call::, Execution>( + Method::POST, + // application_id is caller supplied and typically comes from + // configuration: encoded so that a stray `/`, `?` or `#` cannot + // rewrite the request path + format!("/addons/{}/execute/", encode_path_segment(application_id)?), + None, + Some(json), + ) + } + + /// Gets the status of a started execution. + /// + /// The state is addressed by `request_id` alone — `application_id` does not take + /// part in the lookup, but still has to be an existing one, otherwise the API + /// answers `404`. Pass the same one that was used to start the job. + pub fn status(&self, application_id: &str, request_id: &str) -> Result { + self.client.call::( + Method::GET, + format!( + "/addons/{}/execute/status/", + encode_path_segment(application_id)?, + ), + // request_id is caller supplied: encoded so that a stray `&` or `#` + // cannot rewrite the request + Some(format!("request_id={}", encode_query_value(request_id))), + None, + ) + } + + /// Starts processing a file and polls the status until it settles or `timeout` + /// elapses. + /// + /// Blocks the calling thread. The first poll happens after a second, then the + /// interval grows by half up to 5 seconds — the API has no recommended schedule, + /// and polling more often than once a second buys nothing. + /// + /// `timeout` has to be generous: a virus scan of a half gigabyte file can take + /// tens of minutes. On expiry [`Outcome::Timeout`] is returned with the + /// `request_id`, so polling can be resumed later through [`Service::wait`] + /// instead of starting the job over. + /// + /// ```rust,ignore + /// # use std::time::Duration; + /// # use ucare::addons; + /// + /// let params = addons::ExecuteParams::with_params( + /// "1bac376c-aa7e-4356-861b-dd2657b5bfd1", + /// &addons::RemoveBgParams { + /// crop: Some(true), + /// foreground_type: Some(addons::ForegroundType::Person), + /// ..Default::default() + /// }, + /// )?; + /// + /// match addons_svc.execute_and_wait("remove_bg", params, Duration::from_secs(300))? { + /// addons::Outcome::Done { result, .. } => println!("done: {:?}", result), + /// addons::Outcome::Error { details, .. } => println!("failed: {:?}", details), + /// addons::Outcome::Unknown { .. } => println!("state expired or never existed"), + /// addons::Outcome::Timeout { request_id } => println!("still running: {}", request_id), + /// addons::Outcome::PollFailed { request_id, error } => { + /// println!("cannot poll {}: {}", request_id, error) + /// } + /// } + /// ``` + pub fn execute_and_wait( + &self, + application_id: &str, + params: ExecuteParams, + timeout: Duration, + ) -> Result { + let request_id = self.execute(application_id, params)?.request_id; + + self.wait(application_id, request_id.as_str(), timeout) + } + + /// Polls the status of an already started execution until it settles or `timeout` + /// elapses. See [`Service::execute_and_wait`] for the polling schedule. + pub fn wait( + &self, + application_id: &str, + request_id: &str, + timeout: Duration, + ) -> Result { + let started = Instant::now(); + let mut delay = FIRST_POLL_DELAY; + let mut poll_failures = 0; + let mut unknown_polls = 0; + + loop { + // sleeping before the first poll on purpose: the job has just been + // queued, an immediate request can only answer in_progress + let left = timeout.saturating_sub(started.elapsed()); + if left.is_zero() { + return Ok(Outcome::Timeout { + request_id: request_id.to_string(), + }); + } + thread::sleep(min(delay, left)); + + // a transient poll failure (network blip, 5xx) must not lose the + // request_id of a running, non idempotent job: tolerate a few in a + // row and report the last one through Outcome, keeping the id + let info = match self.status(application_id, request_id) { + Ok(info) => { + poll_failures = 0; + info + } + Err(error) => { + poll_failures += 1; + if poll_failures >= MAX_POLL_FAILURES { + return Ok(Outcome::PollFailed { + request_id: request_id.to_string(), + error, + }); + } + delay = min(delay * 3 / 2, MAX_POLL_DELAY); + continue; + } + }; + match info.status { + Status::Done => { + return Ok(Outcome::Done { + request_id: request_id.to_string(), + result: info.result, + }) + } + Status::Error => { + return Ok(Outcome::Error { + request_id: request_id.to_string(), + details: info.details, + }) + } + Status::Unknown => { + // not treated as terminal right away: see MAX_UNKNOWN_POLLS + unknown_polls += 1; + if unknown_polls >= MAX_UNKNOWN_POLLS { + return Ok(Outcome::Unknown { + request_id: request_id.to_string(), + }); + } + } + Status::InProgress => unknown_polls = 0, + } + + delay = min(delay * 3 / 2, MAX_POLL_DELAY); + } + } +} + +/// Holds all possible params for the execute method +#[derive(Debug, Serialize)] +pub struct ExecuteParams { + /// UUID of the file to process. MUST belong to the project the request is made + /// on behalf of. + pub target: String, + /// Application specific params. The set of them depends on the application. + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl ExecuteParams { + /// Params for an application that takes no options, object recognition + /// for example. + pub fn new(target: &str) -> Self { + ExecuteParams { + target: target.to_string(), + params: None, + } + } + + /// Params for an application that takes options, [`RemoveBgParams`] for example. + /// + /// Accepts anything serializable, including a `serde_json::json!` literal for an + /// application this module has no typed params for. Prefer the typed structs + /// where they exist: the API silently drops unknown keys, so a typo in a hand + /// written literal is never reported — the request succeeds and the option is + /// just not applied. + pub fn with_params(target: &str, params: &T) -> Result + where + T: ?Sized + Serialize, + { + Ok(ExecuteParams { + target: target.to_string(), + params: Some(serde_json::to_value(params)?), + }) + } +} + +/// Holds the execute response data +#[derive(Debug, Deserialize)] +pub struct Execution { + /// Identifier of the started execution, generated by the API. Used to poll + /// the status. + pub request_id: String, +} + +/// Holds the execution status response data +#[derive(Debug, Deserialize)] +pub struct StatusInfo { + /// Current state of the execution. + pub status: Status, + /// Application output. + /// + /// Present only for [`Status::Done`] and only for applications that return one. + /// Most of them do not, so `None` here is not an error — read their output from + /// the file properties instead, see the module docs. + pub result: Option, + /// Machine readable failure description. + /// + /// Not part of the documented status responses, but observed alongside + /// [`Status::Error`] for some applications. `None` is the norm. + pub details: Option
, +} + +/// State of an Add-On execution +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize)] +#[non_exhaustive] +pub enum Status { + /// Accepted and running. + #[serde(rename = "in_progress")] + InProgress, + /// Finished successfully. + #[serde(rename = "done")] + Done, + /// Finished unsuccessfully. + #[serde(rename = "error")] + Error, + /// The API knows nothing about this `request_id`: it never existed, or the + /// state has expired (kept for a limited time, observed to be about a day). + /// The two are indistinguishable, so after a previously seen + /// [`Status::InProgress`] this almost certainly means expiry rather than a + /// bad `request_id`. + #[serde(rename = "unknown")] + Unknown, +} + +/// Machine readable description of a failed execution +#[derive(Debug, Eq, PartialEq, Deserialize)] +pub struct Details { + /// Failure code, `unknown_foreground` for example. + pub code: Option, + /// Human readable failure title. + pub title: Option, +} + +/// Settled state of an Add-On execution, as reported by +/// [`Service::execute_and_wait`] and [`Service::wait`] +#[derive(Debug)] +pub enum Outcome { + /// Finished successfully. + Done { + /// Identifier of the execution. + request_id: String, + /// Application output, `None` for applications that return none. + result: Option, + }, + /// Finished unsuccessfully. + Error { + /// Identifier of the execution. + request_id: String, + /// Failure description, `None` when the failure has no structured one. + details: Option
, + }, + /// The API knows nothing about the execution, see [`Status::Unknown`]. + /// + /// Reported only after several consecutive `unknown` answers: a single one + /// right after the job was accepted is a normal consistency lag, not a + /// reason to re-run a non idempotent job. + Unknown { + /// Identifier of the execution. + request_id: String, + }, + /// The timeout elapsed while the execution was still in progress. Polling can be + /// resumed with [`Service::wait`], the job itself is not affected. + Timeout { + /// Identifier of the execution. + request_id: String, + }, + /// Several consecutive status polls failed before the execution settled. + /// The job itself is not affected; polling can be resumed with + /// [`Service::wait`] — that is why the `request_id` is carried here rather + /// than lost inside an `Err`. + PollFailed { + /// Identifier of the execution. + request_id: String, + /// The error the last poll attempt failed with. + error: Error, + }, +} + +/// Params of the `uc_clamav_virus_scan` application +/// +/// Applies to files of any type, up to 512 MiB. Returns no `result`: the verdict is +/// written into the file properties as `{"infected": false}` or +/// `{"infected": true, "infected_with": "..."}`. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct VirusScanParams { + /// Whether to delete the file if an infection is found. When unset the project + /// setting applies. + /// + /// Note that a successful scan does not guarantee the source file still exists: + /// with deletion on, an infected one is gone and later requests for it answer + /// `404`. + #[serde(skip_serializing_if = "Option::is_none")] + pub purge_infected: Option, +} + +/// Params of the `remove_bg` application +/// +/// Images only. Unlike the other applications it creates a new file and returns its +/// id as `{"file_id": ""}` in the execution result; the source file is left +/// untouched. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct RemoveBgParams { + /// Whether to crop off all empty regions. Defaults to false. + #[serde(skip_serializing_if = "Option::is_none")] + pub crop: Option, + /// Margin around the cropped subject, absolute (`30px`) or relative (`10%`). + /// Defaults to 0. + #[serde(skip_serializing_if = "Option::is_none")] + pub crop_margin: Option, + /// Scale of the subject relative to the total image size, `50%` for example. + #[serde(skip_serializing_if = "Option::is_none")] + pub scale: Option, + /// Whether to add an artificial shadow. Not supported for every subject type. + #[serde(skip_serializing_if = "Option::is_none")] + pub add_shadow: Option, + /// Foreground subject type. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub foreground_type: Option, + /// Classification level of the foreground type. + #[serde(skip_serializing_if = "Option::is_none")] + pub type_level: Option, + /// Whether to allow semi transparent regions in the result. Defaults to true. + #[serde(skip_serializing_if = "Option::is_none")] + pub semitransparency: Option, + /// Whether to return the ready image or just the transparency mask. + #[serde(skip_serializing_if = "Option::is_none")] + pub channels: Option, + /// Region of interest as `x1 y1 x2 y2`. All four values MUST share the unit, + /// either all in `%` or all in `px`: `0% 0% 100px 100px` is rejected. + #[serde(skip_serializing_if = "Option::is_none")] + pub roi: Option, + /// Subject position on the canvas: `original`, `center`, a single percentage or + /// two of them as `horizontal vertical`. + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +/// Foreground subject type for the `remove_bg` application +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +#[non_exhaustive] +pub enum ForegroundType { + /// "auto" + #[serde(rename = "auto")] + Auto, + /// "person" + #[serde(rename = "person")] + Person, + /// "product" + #[serde(rename = "product")] + Product, + /// "car" + #[serde(rename = "car")] + Car, +} + +/// Classification level of the foreground type for the `remove_bg` application +/// +/// Serialized as a string, not a number, which is what the API expects. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +#[non_exhaustive] +pub enum TypeLevel { + /// "1" + #[serde(rename = "1")] + One, + /// "2" + #[serde(rename = "2")] + Two, + /// "none" + #[serde(rename = "none")] + None, + /// "latest" + #[serde(rename = "latest")] + Latest, +} + +/// What the `remove_bg` application should return +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +#[non_exhaustive] +pub enum Channels { + /// "rgba", the ready image + #[serde(rename = "rgba")] + Rgba, + /// "alpha", the transparency mask only + #[serde(rename = "alpha")] + Alpha, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn execute_params_without_application_params() { + let params = ExecuteParams::new("1bac376c-aa7e-4356-861b-dd2657b5bfd1"); + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({"target": "1bac376c-aa7e-4356-861b-dd2657b5bfd1"}), + ); + } + + #[test] + fn execute_params_with_typed_application_params() { + let params = ExecuteParams::with_params( + "1bac376c-aa7e-4356-861b-dd2657b5bfd1", + &RemoveBgParams { + crop: Some(true), + foreground_type: Some(ForegroundType::Person), + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({ + "target": "1bac376c-aa7e-4356-861b-dd2657b5bfd1", + "params": {"crop": true, "type": "person"}, + }), + ); + } + + #[test] + fn remove_bg_type_level_is_a_string() { + let params = RemoveBgParams { + type_level: Some(TypeLevel::Two), + channels: Some(Channels::Alpha), + ..Default::default() + }; + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({"type_level": "2", "channels": "alpha"}), + ); + } + + #[test] + fn virus_scan_params_are_omitted_when_unset() { + assert_eq!( + serde_json::to_value(VirusScanParams::default()).unwrap(), + serde_json::json!({}), + ); + assert_eq!( + serde_json::to_value(VirusScanParams { + purge_infected: Some(true), + }) + .unwrap(), + serde_json::json!({"purge_infected": true}), + ); + } + + #[test] + fn execute_params_accept_a_raw_json_literal() { + // escape hatch for an application with no typed params here + let params = ExecuteParams::with_params( + "1bac376c-aa7e-4356-861b-dd2657b5bfd1", + &serde_json::json!({"something_new": 1}), + ) + .unwrap(); + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({ + "target": "1bac376c-aa7e-4356-861b-dd2657b5bfd1", + "params": {"something_new": 1}, + }), + ); + } + + #[test] + fn execution_deserializes() { + let json = r#"{"request_id": "9b27ff0b-b1c3-4c1f-9a4a-5bb5e5d8e4c2"}"#; + let execution: Execution = serde_json::from_str(json).unwrap(); + + assert_eq!(execution.request_id, "9b27ff0b-b1c3-4c1f-9a4a-5bb5e5d8e4c2"); + } + + #[test] + fn status_in_progress() { + let info: StatusInfo = serde_json::from_str(r#"{"status": "in_progress"}"#).unwrap(); + + assert_eq!(info.status, Status::InProgress); + assert!(info.result.is_none()); + assert!(info.details.is_none()); + } + + #[test] + fn status_done_with_result() { + let json = r#"{ + "status": "done", + "result": {"file_id": "b0ea3b6f-0e5c-4a2d-9c65-8a2a6f8bd0e1"} + }"#; + let info: StatusInfo = serde_json::from_str(json).unwrap(); + + assert_eq!(info.status, Status::Done); + assert_eq!( + info.result.unwrap()["file_id"], + serde_json::json!("b0ea3b6f-0e5c-4a2d-9c65-8a2a6f8bd0e1"), + ); + } + + #[test] + fn status_done_without_result() { + // how success looks for every application but remove_bg, must not be an error + let info: StatusInfo = serde_json::from_str(r#"{"status": "done"}"#).unwrap(); + + assert_eq!(info.status, Status::Done); + assert!(info.result.is_none()); + } + + #[test] + fn status_error_with_details() { + let json = r#"{ + "status": "error", + "details": { + "code": "unknown_foreground", + "title": "Could not identify foreground in image" + } + }"#; + let info: StatusInfo = serde_json::from_str(json).unwrap(); + + assert_eq!(info.status, Status::Error); + assert_eq!( + info.details, + Some(Details { + code: Some("unknown_foreground".to_string()), + title: Some("Could not identify foreground in image".to_string()), + }), + ); + } + + #[test] + fn status_error_without_details() { + let info: StatusInfo = serde_json::from_str(r#"{"status": "error"}"#).unwrap(); + + assert_eq!(info.status, Status::Error); + assert!(info.details.is_none()); + } + + #[test] + fn status_unknown() { + let info: StatusInfo = serde_json::from_str(r#"{"status": "unknown"}"#).unwrap(); + + assert_eq!(info.status, Status::Unknown); + } +} diff --git a/src/conversion.rs b/src/conversion.rs index 38ab85a..c10d9bb 100644 --- a/src/conversion.rs +++ b/src/conversion.rs @@ -1,7 +1,10 @@ //! Holds all primitives and logic related to file conversion. //! -//! Uploadcare allows converting documents to the following target formats: -//! DOC, DOCX, XLS, XLSX, ODT, ODS, RTF, TXT, PDF, JPG, PNG. +//! Since APIv0.7 the target format is an arbitrary string rather than a value from +//! a fixed list: whether a conversion is possible is decided by the API for every +//! particular source file. Use [`Service::document_info`] to find out what a given +//! document can be converted to instead of keeping a format matrix on the client +//! side. use std::collections::HashMap; use std::fmt::Debug; @@ -17,7 +20,7 @@ pub struct Service<'a> { } /// creates an instance of the conversion service -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } @@ -27,7 +30,7 @@ impl Service<'_> { let json = encode_json(¶ms)?; self.client.call::, JobResult>( Method::POST, - format!("/convert/document/"), + "/convert/document/".to_string(), None, Some(json), ) @@ -43,22 +46,39 @@ impl Service<'_> { ) } + /// Gets information about a document available for conversion: its source + /// format, the formats it can be converted to and the groups it has already + /// been converted into. + /// + /// Available since APIv0.7 only. This is the supported way of finding out what + /// a particular file can be converted to. + pub fn document_info(&self, file_id: &str) -> Result { + self.client.call::( + Method::GET, + format!("/convert/document/{}/", file_id), + None, + None, + ) + } + /// Starts video conversion job pub fn video(&self, params: JobParams) -> Result { let json = encode_json(¶ms)?; self.client.call::, JobResult>( Method::POST, - format!("/convert/video"), + // with the trailing slash: without it the API answers with a + // redirect, which is not followed for a POST with a body + "/convert/video/".to_string(), None, Some(json), ) } /// Gets video conversion job status - pub fn video_status(&self, token: i32) -> Result { + pub fn video_status(&self, token: i64) -> Result { self.client.call::( - Method::POST, - format!("convert/video/status/{}/", token), + Method::GET, + format!("/convert/video/status/{}/", token), None, None, ) @@ -75,7 +95,7 @@ pub struct JobParams { /// /// You can also provide a complete CDN URL. It can then be used as an /// alias to your converted file ID (UUID): - /// https://ucarecdn.com/:uuid/document/-/format/:target-format/ + /// `https://ucarecdn.com/:uuid/document/-/format/:target-format/` /// /// :uuid identifies the source file you want to convert, it should be /// followed by /document/, otherwise, your request will return an error. @@ -84,18 +104,36 @@ pub struct JobParams { /// /// The following operations are available during conversion: /// /format/:target-format/ defines the target format you want a source - /// file converted to. The supported values for :target-format are: doc, - /// docx, xls, xlsx, odt, ods, rtf, txt, pdf (default), jpg, png. In case - /// the /format/ operation was not found, your input document will be - /// converted to pdf. Note, when converting multi-page documents to image - /// formats (jpg or png), your output will be a zip archive holding a - /// number of images corresponding to the input page count. + /// file converted to. Since APIv0.7 :target-format is an arbitrary string + /// and no longer a value from a fixed list: whether the source format can + /// be converted to it is checked by the API for this particular file, at + /// request validation time. An impossible pair is reported as a bad request + /// saying "Document conversion from X to Y format is not supported.", and + /// [`Service::document_info`] is the way to learn the available ones + /// upfront. In case the /format/ operation was not found, your input + /// document will be converted to pdf. Note, when converting multi-page + /// documents to image formats (jpg or png), your output will be a zip + /// archive holding a number of images corresponding to the input page + /// count. /// /page/:number/ converts a single page of a multi-paged document to /// either jpg or png. The method will not work for any other target /// formats. :number stands for the one-based number of a page to convert. + /// It MUST be the last operation in the chain. + /// /dpi/:value/ and /quality/:value/ were added in APIv0.7 and apply to + /// the jpg target format only, any other one is an error. Both accept a + /// value from a service defined list rather than an arbitrary number, and + /// the error message for a rejected one holds the allowed values. pub paths: Vec, /// Flag indicating if we should store your outputs. + #[serde(skip_serializing_if = "Option::is_none")] pub store: Option, + /// When `True`, the outputs of a multi-page conversion are additionally + /// saved as a file group. Defaults to `False` on the API side. + /// + /// Documented for document conversion only, leave `None` for + /// [`Service::video`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub save_in_group: Option, } /// MUST be either true or false @@ -125,13 +163,87 @@ pub struct JobInfo { pub uuid: String, /// UUID of a file group with thumbnails for an output video, /// based on the `thumbs` operation parameters - pub thumbnails_group_id: Option, + pub thumbnails_group_uuid: Option, /// Source file identifier including a target format, if present pub original_source: Option, /// Conversion job token that can be used to get a job status pub token: Option, } +/// Conversion job status request result +#[derive(Debug, Deserialize)] +pub struct StatusResult { + /// Status holds conversion job status, can be one of the following: + /// pending — a source file is being prepared for conversion. + /// processing — conversion is in progress. + /// finished — the conversion is finished. + /// failed — we failed to convert the source, see error for details. + /// canceled — the conversion was canceled. + pub status: String, + /// Conversion error if we were unable to handle your file + pub error: Option, + /// Result repeats the contents of your processing output. + /// + /// `None` while the job has not produced one (`pending`, `failed`): a + /// failed job reports the reason through `error` and carries no result. + pub result: Option, +} + +/// Information about a document available for conversion +#[derive(Debug, Deserialize)] +pub struct DocumentInfo { + /// Error description if the document cannot be handled. + pub error: Option, + /// Source document format together with everything it can be + /// and has already been converted to. + pub format: Option, + /// Groups the document has already been converted into, keyed by the + /// target format. + /// + /// The documentation is self-contradictory about where this map lives: the + /// OpenAPI schema puts it here, at the top level, while the rendered + /// example nests it inside `format`. Both placements are accepted, use + /// [`DocumentInfo::any_converted_groups`] to not care. + #[serde(default)] + pub converted_groups: HashMap, +} + +impl DocumentInfo { + /// The `converted_groups` map wherever the API put it: the top level one + /// when present, the one nested in `format` otherwise. + pub fn any_converted_groups(&self) -> &HashMap { + if !self.converted_groups.is_empty() { + return &self.converted_groups; + } + self.format + .as_ref() + .map(|f| &f.converted_groups) + .unwrap_or(&self.converted_groups) + } +} + +/// Source document format +#[derive(Debug, Deserialize)] +pub struct DocumentFormat { + /// Format name, `docx` for example. + pub name: Option, + /// Formats this particular document can be converted to. + #[serde(default)] + pub conversion_formats: Vec, + /// Groups the document has already been converted into, keyed by the + /// target format. See [`DocumentInfo::converted_groups`] for the placement + /// caveat. + #[serde(default)] + pub converted_groups: HashMap, +} + +/// A target format available for a document +#[derive(Debug, Deserialize)] +pub struct ConversionFormat { + /// Format name, `docx` for example. + pub name: Option, +} + #[cfg(test)] mod tests { use super::*; @@ -145,20 +257,109 @@ mod tests { let info: JobInfo = serde_json::from_str(json).unwrap(); assert_eq!(info.token, Some(3_000_000_000_i64)); } -} -/// Conversion job status request result -#[derive(Debug, Deserialize)] -pub struct StatusResult { - /// Status holds conversion job status, can be one of the following: - /// pending — a source file is being prepared for conversion. - /// processing — conversion is in progress. - /// finished — the conversion is finished. - /// failed — we failed to convert the source, see error for details. - /// canceled — the conversion was canceled. - pub status: String, - /// Conversion error if we were unable to handle your file - pub error: Option, - /// Result repeats the contents of your processing output - pub result: JobInfo, + #[test] + fn document_info_deserializes() { + let json = r#"{ + "error": null, + "format": { + "name": "docx", + "conversion_formats": [ + {"name": "pdf"}, + {"name": "png"}, + {"name": "txt"} + ], + "converted_groups": { + "pdf": "badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1", + "png": "e4c9d0a3-1b2c-4d5e-8f70-1a2b3c4d5e6f~3" + } + } + }"#; + let info: DocumentInfo = serde_json::from_str(json).unwrap(); + + assert_eq!(info.error, None); + + // this fixture follows the docs example: converted_groups nested + // inside format + assert_eq!(info.any_converted_groups().len(), 2); + assert_eq!( + info.any_converted_groups().get("pdf"), + Some(&"badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1".to_string()), + ); + + let format = info.format.unwrap(); + assert_eq!(format.name, Some("docx".to_string())); + assert_eq!(format.conversion_formats.len(), 3); + assert_eq!(format.conversion_formats[0].name, Some("pdf".to_string())); + } + + #[test] + fn document_info_accepts_top_level_converted_groups() { + // ... while the docs OpenAPI schema puts converted_groups at the top + // level of the response + let json = r#"{ + "error": null, + "format": {"name": "docx", "conversion_formats": [{"name": "pdf"}]}, + "converted_groups": {"pdf": "badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1"} + }"#; + let info: DocumentInfo = serde_json::from_str(json).unwrap(); + + assert_eq!(info.any_converted_groups().len(), 1); + assert_eq!( + info.any_converted_groups().get("pdf"), + Some(&"badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1".to_string()), + ); + } + + #[test] + fn document_info_without_converted_groups() { + let json = r#"{"format": {"name": "jpeg", "conversion_formats": []}}"#; + let info: DocumentInfo = serde_json::from_str(json).unwrap(); + + assert_eq!(info.error, None); + assert!(info.any_converted_groups().is_empty()); + + let format = info.format.unwrap(); + assert!(format.conversion_formats.is_empty()); + assert!(format.converted_groups.is_empty()); + } + + #[test] + fn status_result_without_result() { + // a failed job reports the reason through `error` and has no result + let json = r#"{"status": "failed", "error": "sources unavailable"}"#; + let status: StatusResult = serde_json::from_str(json).unwrap(); + + assert_eq!(status.status, "failed"); + assert_eq!(status.error, Some("sources unavailable".to_string())); + assert!(status.result.is_none()); + } + + #[test] + fn job_info_thumbnails_group_uses_the_documented_name() { + let json = r#"{ + "uuid": "a18983d0-b0d7-4c8d-968b-2e6d2e1c3ea1", + "thumbnails_group_uuid": "badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1" + }"#; + let info: JobInfo = serde_json::from_str(json).unwrap(); + + assert_eq!( + info.thumbnails_group_uuid, + Some("badfc9f7-f88f-4921-9cc0-22e2c08aa2da~1".to_string()), + ); + } + + #[test] + fn job_params_omit_unset_flags() { + let params = JobParams { + paths: vec!["uuid/document/-/format/pdf/".to_string()], + store: None, + save_in_group: None, + }; + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({"paths": ["uuid/document/-/format/pdf/"]}), + ); + } } diff --git a/src/file.rs b/src/file.rs index f91e575..47bfb8b 100644 --- a/src/file.rs +++ b/src/file.rs @@ -10,10 +10,14 @@ use std::collections::HashMap; use std::fmt::{self, Debug, Display}; use reqwest::{Method, Url}; -use serde::{self, Deserialize, Serialize}; +use serde::{self, ser::SerializeMap, Deserialize, Serialize, Serializer}; use serde_json; -use crate::ucare::{encode_json, rest::Client, IntoUrlQuery, Result}; +pub use crate::types::{AudioStream, ContentInfo, MimeInfo, VideoInfo, VideoStream}; +use crate::ucare::{ + encode_json, encode_query_value, is_dot_segment, rest::Client, ErrValue, Error, IntoUrlQuery, + Result, +}; /// Service is used to make calls to file API. pub struct Service<'a> { @@ -21,17 +25,22 @@ pub struct Service<'a> { } /// creates an instance of the file service -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } impl Service<'_> { /// Acquires some file specific info - pub fn info(&self, file_id: &str) -> Result { + /// + /// Pass `Some(Include::Appdata)` to get the `appdata` field populated, `None` + /// for a regular request. + pub fn info(&self, file_id: &str, include: Option) -> Result { + let query = include.map(|val| format!("include={}", val)); + self.client.call::( Method::GET, format!("/files/{}/", file_id), - None, + query, None, ) } @@ -42,9 +51,12 @@ impl Service<'_> { /// # use ucare::file; /// /// let params = file::ListParams{ + /// removed: Some(file::Filter::False), + /// stored: Some(file::Filter::All), /// limit: Some(10), - /// ordering: Some(file::Ordering::Size), + /// ordering: Some(file::Ordering::DatetimeUploaded), /// from: None, + /// include: None, /// }; /// let list = file_svc.list(params)?; /// let mut next_page = list.next; @@ -63,7 +75,7 @@ impl Service<'_> { pub fn list(&self, params: ListParams) -> Result { self.client.call::( Method::GET, - format!("/files/"), + "/files/".to_string(), Some(params), None, ) @@ -75,6 +87,49 @@ impl Service<'_> { self.client.call_url::(Method::GET, url, None) } + /// Searches for the project files. Available since APIv0.7 only. + /// + /// At least one of the [`SearchQuery`] criteria has to be set, otherwise the API + /// answers `400`. The same goes for the rest of the constraints documented on + /// [`SearchQuery`] and [`SearchParams`] — they are checked server side and + /// reported back as `ErrValue::BadRequest`, the library does not duplicate the + /// validation to avoid being stricter than the service. + /// + /// ```rust,ignore + /// # use ucare::file; + /// + /// let params = file::SearchParams { + /// query: file::SearchQuery { + /// query: Some("invoice".to_string()), + /// is_image: Some(false), + /// ..Default::default() + /// }, + /// limit: Some(50), + /// offset: None, + /// include: None, + /// }; + /// let found = file_svc.search(params)?; + /// + /// for f in found.results.unwrap().iter() { + /// println!("{}: {:?}", f.info.uuid, f.highlight.original_filename); + /// } + /// ``` + /// + /// Note that the search index lags behind the actual state by tens of seconds: + /// a freshly uploaded file may not be found yet, and a freshly deleted one may + /// still be listed. + pub fn search(&self, params: SearchParams) -> Result { + let query = params.pagination_query(); + let json = encode_json(¶ms.query)?; + + self.client.call::, SearchList>( + Method::POST, + "/files/search/".to_string(), + query, + Some(json), + ) + } + /// Store a single file by its id pub fn store(&self, file_id: &str) -> Result { self.client.call::( @@ -91,7 +146,7 @@ impl Service<'_> { let json = encode_json(&file_ids)?; self.client.call::, BatchInfo>( Method::PUT, - format!("/files/storage/"), + "/files/storage/".to_string(), None, Some(json), ) @@ -113,18 +168,7 @@ impl Service<'_> { let json = encode_json(&file_ids)?; self.client.call::, BatchInfo>( Method::DELETE, - format!("/files/storage/"), - None, - Some(json), - ) - } - - /// Copy is the APIv05 version of the LocalCopy and RemoteCopy, use them instead - pub fn copy(&self, params: CopyParams) -> Result { - let json = encode_json(¶ms)?; - self.client.call::, LocalCopyInfo>( - Method::POST, - format!("/files/"), + "/files/storage/".to_string(), None, Some(json), ) @@ -133,19 +177,16 @@ impl Service<'_> { /// Used to copy original files or their modified versions to /// default storage. Source files MAY either be stored or just uploaded and MUST /// NOT be deleted - pub fn local_copy(&self, mut params: CopyParams) -> Result { - if let None = params.store { - params.store = Some(ToStore::False); - } - if let None = params.make_public { - params.make_public = Some(MakePublic::True); - } - + /// + /// Fields of [`CopyParams`] not documented for local copy (`make_public`, + /// `target`, `pattern`) are left to the caller; unset fields are not sent and + /// the API defaults apply (`store` defaults to `false`). + pub fn local_copy(&self, params: CopyParams) -> Result { let json = encode_json(¶ms)?; self.client.call::, LocalCopyInfo>( Method::POST, - format!("/files/local_copy/"), + "/files/local_copy/".to_string(), None, Some(json), ) @@ -154,20 +195,141 @@ impl Service<'_> { /// Used to copy original files or their modified versions to a custom /// storage. Source files MAY either be stored or just uploaded and MUST NOT be /// deleted. - pub fn remote_copy(&self, mut params: CopyParams) -> Result { - if let None = params.make_public { - params.make_public = Some(MakePublic::True); - } - + pub fn remote_copy(&self, params: CopyParams) -> Result { let json = encode_json(¶ms)?; self.client.call::, RemoteCopyInfo>( Method::POST, - format!("/files/remote_copy/"), + "/files/remote_copy/".to_string(), + None, + Some(json), + ) + } + + /// Returns the tags of a file: `GET /files/{uuid}/tags/`. + pub fn tags(&self, file_id: &str) -> Result { + self.client.call::( + Method::GET, + format!("/files/{}/tags/", file_id), + None, + None, + ) + } + + /// Replaces the whole set of file tags: `PUT /files/{uuid}/tags/`. + /// + /// Up to 50 tags per file, each up to 100 characters of latin letters, digits, + /// `-`, `_` and `.`. The API normalizes the values — lowercases them, strips + /// whitespace, discards empty ones and drops duplicates keeping the first + /// occurrence — so [`TagsUpdate::tags`] in the response may differ from what + /// was sent, both in content and in length. + pub fn set_tags(&self, file_id: &str, tags: &[&str]) -> Result { + let json = encode_json(&serde_json::json!({ "tags": tags }))?; + + self.client.call::, TagsUpdate>( + Method::PUT, + format!("/files/{}/tags/", file_id), None, Some(json), ) } + + /// Adds and/or removes individual file tags: `PATCH /files/{uuid}/tags/`. + /// + /// Unlike [`Service::set_tags`] the tags not mentioned in either list are + /// left as they are. + pub fn update_tags(&self, file_id: &str, add: &[&str], delete: &[&str]) -> Result { + let json = encode_json(&serde_json::json!({ "add": add, "delete": delete }))?; + + self.client.call::, TagsUpdate>( + Method::PATCH, + format!("/files/{}/tags/", file_id), + None, + Some(json), + ) + } + + /// Returns all metadata of a file: `GET /files/{uuid}/metadata/`. + pub fn metadata(&self, file_id: &str) -> Result> { + self.client.call::>( + Method::GET, + format!("/files/{}/metadata/", file_id), + None, + None, + ) + } + + /// Returns the value of a single metadata key: + /// `GET /files/{uuid}/metadata/{key}/`. + pub fn metadata_value(&self, file_id: &str, key: &str) -> Result { + validate_metadata_key(key)?; + + self.client.call::( + Method::GET, + format!("/files/{}/metadata/{}/", file_id, key), + None, + None, + ) + } + + /// Creates or updates the value of a single metadata key: + /// `PUT /files/{uuid}/metadata/{key}/`. Returns the stored value. + /// + /// Values are limited to 512 characters, a file can hold up to 50 keys. + pub fn set_metadata_value(&self, file_id: &str, key: &str, value: &str) -> Result { + validate_metadata_key(key)?; + // the documented request body is a bare json string + let json = encode_json(&value)?; + + self.client.call::, String>( + Method::PUT, + format!("/files/{}/metadata/{}/", file_id, key), + None, + Some(json), + ) + } + + /// Removes a single metadata key: `DELETE /files/{uuid}/metadata/{key}/`. + pub fn delete_metadata_value(&self, file_id: &str, key: &str) -> Result<()> { + validate_metadata_key(key)?; + + self.client.call::( + Method::DELETE, + format!("/files/{}/metadata/{}/", file_id, key), + None, + None, + ) + } +} + +/// Checks a metadata key against the documented constraints before it is put +/// into the request path. +/// +/// Keys are limited to 64 characters of `a-z A-Z 0-9 _ - . :`. Rejecting +/// anything else client side both mirrors the API behavior (it ignores such +/// keys) and keeps unencoded user input out of the URL. +/// +/// `.` and `..` pass that charset but are path segments with a meaning of their +/// own: `Url::parse` normalizes `/files/{uuid}/metadata/../` down to +/// `/files/{uuid}/`, which would turn a metadata delete into a delete of the +/// file itself. They are rejected separately. +fn validate_metadata_key(key: &str) -> Result<()> { + let valid = !key.is_empty() + && key.len() <= 64 + && !is_dot_segment(key) + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')); + + if valid { + Ok(()) + } else { + Err(Error::with_value(ErrValue::BadRequest(format!( + "invalid metadata key {:?}: up to 64 characters of a-z, A-Z, 0-9, `_-.:`, \ + and neither `.` nor `..`", + key, + )))) + } } /// Info holds file specific information @@ -178,152 +340,93 @@ pub struct Info { /// Date and time when a file was removed, if any. pub datetime_removed: Option, /// Date and time of the last store request, if any. + /// + /// Also set for a file that is not in the storage yet but was marked to be stored + /// on upload, in which case it holds the upload time. pub datetime_stored: Option, /// Date and time when a file was uploaded. pub datetime_uploaded: Option, - /// Image metadata - pub image_info: Option, /// Is file is image. + /// + /// Three-state: `Some(true)` is a recognized image, `Some(false)` is definitely + /// not an image and `None` means recognition has not finished yet. pub is_image: Option, /// Is file is ready to be used after upload. pub is_ready: Option, - /// File MIME-type. + /// File MIME-type as declared on upload. + /// + /// The detected one is in `content_info.mime`. pub mime_type: Option, /// Publicly available file CDN URL. Available if a file is not deleted. pub original_file_url: Option, /// Original file name taken from uploaded file. pub original_filename: Option, /// File size in bytes. - pub size: Option, + pub size: Option, /// API resource URL for a particular file. pub url: Option, /// Dictionary of other files that has been created using this file as source. Used for video, /// document and etc. conversion. pub variations: Option, - /// Video info - pub video_info: Option, - /// File upload source. This field contains information about from where file was uploaded, for - /// example: facebook, gdrive, gphotos, etc. - pub source: Option, - /// Dictionary of file categories with it\"s confidence. - pub rekognition_info: Option>, + /// Recognized content information: mime type, image and video metadata. + /// + /// Replaces `image_info` and `video_info` of APIv0.6. Is `None` for files whose + /// content was never recognized or the recognition failed. + pub content_info: Option, + /// Arbitrary user defined `key -> value` pairs attached to the file. + /// + /// The API always returns an object here, an empty one when there is no metadata, + /// hence not an `Option`. A missing field and an explicit `null` — which webhook + /// deliveries are documented to carry — both come out as an empty map rather than + /// failing the whole response. + #[serde(default, deserialize_with = "crate::ucare::de_null_as_default")] + pub metadata: HashMap, + /// File tags, ordered by their first occurrence; an empty vector when the file + /// has no tags. + /// + /// `Option` defensively: the field is part of every documented v0.7 response, + /// but payloads produced elsewhere (webhook deliveries for example) may omit it. + pub tags: Option>, + /// Results produced by applications (virus scan, object recognition and so on), + /// keyed by the application id. + /// + /// Only present when `appdata` was asked for through the `include` argument + /// of [`Service::info`], [`ListParams::include`] or [`SearchParams::include`], + /// otherwise `None`. + pub appdata: Option>, } -/// ImageInfo holds image-specific information +/// Result produced by a single application for a file #[derive(Debug, Deserialize)] -pub struct ImageInfo { - /// Image color mode. - pub color_mode: Option, - /// Image orientation from EXIF. - pub orientation: Option, - /// Image format. - pub format: Option, - /// Image sequence - pub sequence: Option, - /// Image height in pixels. - pub height: Option, - /// Image width in pixels. - pub width: Option, - /// Image geo location. - pub geo_location: Option, - /// Image date and time from EXIF. - pub datetime_original: Option, - /// Image DPI for two dimensions. - pub dpi: Option>, -} - -/// Image geo location -#[derive(Debug, Deserialize)] -pub struct ImageInfoGeoLocation { - /// Location latitude. - pub latitude: Option, - /// Location longitude. - pub longitude: Option, -} - -/// Image color mode. -#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize)] -pub enum ColorMode { - /// RGB - RGB, - /// RGBA - RGBA, - /// RGBa - RGBa, - /// RGBX - RGBX, - /// L - L, - /// LA - LA, - /// La - La, - /// P - P, - /// PA - PA, - /// CMYK - CMYK, - /// YCbCr - YCbCr, - /// HSV - HSV, - /// LAB - LAB, -} - -/// Video related information -#[derive(Debug, PartialEq, Deserialize)] -pub struct VideoInfo { - /// Video duration in milliseconds. - pub duration: Option, - /// Video format (MP4 for example). - pub format: Option, - /// Video bitrate. - pub bitrate: Option, - /// Audio information - pub audio: Option, - /// Video stream info - pub video: Option, -} - -/// Information about the audio in video -#[derive(Debug, PartialEq, Deserialize)] -pub struct VideoInfoAudio { - /// Audio stream metadata. - pub bitrate: Option, - /// Audio stream codec. - pub codec: Option, - /// Audio stream sample rate. - pub sample_rate: Option, - /// Audio stream number of channels. - pub channels: Option, -} - -/// Video stream info -#[derive(Debug, PartialEq, Deserialize)] -pub struct VideoInfoVideo { - /// Video stream image height. - pub height: Option, - /// Video stream image width. - pub width: Option, - /// Video stream frame rate. - pub frame_rate: Option, - /// Video stream bitrate. - pub bitrate: Option, - /// Video stream codec. - pub codec: Option, +pub struct AppDataEntry { + /// Application output. + /// + /// Opaque on purpose: the shape is defined by the application and its `version`, + /// so it is left as raw json rather than validated. + #[serde(default)] + pub data: serde_json::Value, + /// Version of the application data format. + pub version: Option, + /// When the entry was created. + pub datetime_created: Option, + /// When the entry was last updated. + pub datetime_updated: Option, } /// Holds all possible params for for the list method pub struct ListParams { - /// Is set to true if only include removed files in the response, - /// otherwise existing files are included. Defaults to false. - pub removed: Option, - /// Is set to true if only include files that were stored. - /// Set to false to include only temporary files. - /// The default is unset: both stored and not stored files are returned - pub stored: Option, + /// Set to `Filter::True` to only include removed files in the response and + /// `Filter::False` to only include existing ones. Unset means the documented + /// API default, which is `false` — existing files only. + /// + /// There is no way to get removed and existing files in one listing: + /// `Filter::All` sends no parameter, so it is the same as leaving this unset + /// and it does **not** combine the two. List them separately. + pub removed: Option, + /// Set to `Filter::True` to only include files that were stored, + /// `Filter::False` to only include temporary ones and `Filter::All` to include + /// both. The default is unset, which is the same as `Filter::All`. + pub stored: Option, /// Specifies preferred amount of files in a list for a single /// response. Defaults to 100, while the maximum is 1000 pub limit: Option, @@ -333,19 +436,54 @@ pub struct ListParams { /// Specifies a starting point for filtering files. /// The value depends on your ordering parameter value. pub from: Option, + /// Additional fields to be included into every returned file. + pub include: Option, +} + +/// A three valued filter for the list method. +/// +/// The documented contract only knows the boolean values, so `All` sends no +/// parameter at all and the API default applies. For `stored` that default is +/// "any storage state" — exactly what `All` promises. For `removed` the +/// documented default is `false`: there is no documented way to get existing +/// and removed files in one listing, so `removed: Some(All)` behaves the same +/// as leaving it unset. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Filter { + /// "true" + True, + /// "false" + False, + /// The parameter is not sent, the API default applies. + All, +} + +impl Filter { + /// The query string value, `None` for the [`Filter::All`] no-op. + fn query_value(&self) -> Option<&'static str> { + match *self { + Filter::True => Some("true"), + Filter::False => Some("false"), + Filter::All => None, + } + } } /// Specifies the way files are sorted in a returned list. /// By default is set to datetime_uploaded. +/// +/// Sorting by size was supported by APIv0.6 but is gone in v0.7: it breaks cursor +/// based pagination when a whole page holds files of the same size. Any unsupported +/// value now makes the API answer `400` instead of silently falling back to the +/// default, so keeping this an enum is what keeps such a request from being made. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] pub enum Ordering { /// "datetime_uploaded" DatetimeUploaded, /// "-datetime_uploaded" DatetimeUploadedNeg, - /// "size" - Size, - /// "-size" - SizeNeg, } impl Display for Ordering { @@ -353,8 +491,29 @@ impl Display for Ordering { let val = match *self { Ordering::DatetimeUploaded => "datetime_uploaded", Ordering::DatetimeUploadedNeg => "-datetime_uploaded", - Ordering::Size => "size", - Ordering::SizeNeg => "-size", + }; + + write!(f, "{}", val) + } +} + +/// Additional fields to be included into the response. +/// +/// Replaces the `add_fields=rekognition_info` parameter of APIv0.6. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[non_exhaustive] +pub enum Include { + /// Include `appdata` into every returned file. + /// + /// Notably more expensive than a regular request, since it pulls in the related + /// application records, so do not enable it by default. + Appdata, +} + +impl Display for Include { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let val = match *self { + Include::Appdata => "appdata", }; write!(f, "{}", val) @@ -363,43 +522,30 @@ impl Display for Ordering { impl IntoUrlQuery for ListParams { fn into_query(self) -> String { - let mut q = String::new(); - q.push_str("removed="); - if let Some(val) = self.removed { - q.push_str(val.to_string().as_str()); - } else { - q.push_str("false"); + // unset parameters are not sent at all: the server side defaults are + // documented and there is no point in re-stating them client side + let mut parts: Vec = Vec::new(); + if let Some(val) = self.removed.as_ref().and_then(Filter::query_value) { + parts.push(format!("removed={}", val)); } - q.push('&'); - - if let Some(val) = self.stored { - q.push_str("stored="); - q.push_str(val.to_string().as_str()); - q.push('&'); + if let Some(val) = self.stored.as_ref().and_then(Filter::query_value) { + parts.push(format!("stored={}", val)); } - - q.push_str("limit="); if let Some(val) = self.limit { - q.push_str(val.to_string().as_str()); - } else { - q.push_str("1000"); + parts.push(format!("limit={}", val)); } - q.push('&'); - - q.push_str("ordering="); if let Some(val) = self.ordering { - q.push_str(val.to_string().as_str()); - } else { - q.push_str(Ordering::DatetimeUploaded.to_string().as_str()); + parts.push(format!("ordering={}", val)); } - - if let Some(val) = self.from { - q.push('&'); - q.push_str("from="); - q.push_str(val.as_str()); + if let Some(ref val) = self.from { + // an ISO 8601 cursor may hold `+`, which must not turn into a space + parts.push(format!("from={}", encode_query_value(val))); + } + if let Some(val) = self.include { + parts.push(format!("include={}", val)); } - q + parts.join("&") } } @@ -415,25 +561,310 @@ pub struct List { /// A total number of objects of the queried type. For files, the queried type depends on /// the stored and removed query parameters. pub total: Option, + /// Number of files in the project broken down by their storage state, + /// regardless of the query parameters. + pub totals: Option, /// Number of objects per page. pub per_page: Option, } -/// MUST be either true or false +/// A breakdown of the project files by their storage state +#[derive(Debug, Eq, PartialEq, Deserialize)] +pub struct Totals { + /// Number of files marked as removed. + pub removed: Option, + /// Number of files in the storage. + pub stored: Option, + /// Number of uploaded but not stored files. + pub unstored: Option, +} + +/// Holds all possible params for the search method +/// +/// The `query` goes into the request body, the rest of the fields into the query +/// string. +#[derive(Debug, Default)] +pub struct SearchParams { + /// What to search for. + pub query: SearchQuery, + /// Preferred amount of files in a single response, 1 to 100. + /// Defaults to 20 on the API side. + pub limit: Option, + /// Number of files to skip. Defaults to 0. + /// + /// `offset + limit` MUST NOT exceed 1000, otherwise the API answers `400`. This + /// is a hard cap on the result depth: walking the whole project through the + /// search is not possible, use [`Service::list`] for that. + pub offset: Option, + /// Additional fields to be included into every found file. + pub include: Option, +} + +impl SearchParams { + /// Builds the query string part of the search request, `None` when there is + /// nothing to put into it. + fn pagination_query(&self) -> Option { + let mut parts: Vec = Vec::new(); + if let Some(val) = self.limit { + parts.push(format!("limit={}", val)); + } + if let Some(val) = self.offset { + parts.push(format!("offset={}", val)); + } + if let Some(ref val) = self.include { + parts.push(format!("include={}", val)); + } + + if parts.is_empty() { + return None; + } + Some(parts.join("&")) + } +} + +/// Search criteria. At least one of the fields MUST be set. +#[derive(Debug, Default, Serialize)] +pub struct SearchQuery { + /// Full text search over several fields at once. At least 4 characters long. + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, + /// Substring search in specific fields. + #[serde(skip_serializing_if = "Option::is_none")] + pub phrase: Option, + /// Exact match search. + #[serde(skip_serializing_if = "Option::is_none")] + pub exact: Option, + /// Upload time range. + #[serde(skip_serializing_if = "Option::is_none")] + pub datetime_uploaded: Option, + /// File size range, in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Whether the file is a recognized image. + /// + /// The documented contract is strictly boolean, there is no value for + /// "recognition has not finished yet". + #[serde(skip_serializing_if = "Option::is_none")] + pub is_image: Option, + /// File tags to match. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, + /// Allow for typos in the full text search. Defaults to false. + #[serde(skip_serializing_if = "Option::is_none")] + pub fuzziness: Option, + /// Result ordering, up to 4 entries. + /// + /// Duplicates and opposite directions of the same field (`Size` together with + /// `SizeNeg`) are rejected with a `400`. + #[serde(skip_serializing_if = "Option::is_none")] + pub sort: Option>, +} + +/// Substring search in specific fields. +/// +/// Every value has to be at least 4 characters long. A field MUST NOT be used in +/// both [`Phrase`] and [`Exact`] within one query, that is a `400`. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct Phrase { + /// Search in the detected MIME type. + #[serde(skip_serializing_if = "Option::is_none")] + pub detected_mime_type: Option, + /// Search in the file metadata values. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Search in the original file name. + #[serde(skip_serializing_if = "Option::is_none")] + pub original_filename: Option, +} + +/// Exact match search. Every set field has to hold a non empty list of values. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct Exact { + /// Match any of the given UUIDs. + pub uuid: Option>, + /// Match any of the given detected MIME types. + pub detected_mime_type: Option>, + /// Match any of the given original file names. + pub original_filename: Option>, + /// Match file metadata: `metadata key -> any of the given values`. + /// + /// Serialized as `metadata[key]` entries next to the fields above. Keys are + /// limited to 64 characters and values to 512, same as the file metadata itself. + pub metadata: HashMap>, +} + +impl Serialize for Exact { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + // the `metadata[key]` keys cannot be expressed with a derive, hence the + // hand written map + let mut len = self.metadata.len(); + for field in [ + &self.uuid, + &self.detected_mime_type, + &self.original_filename, + ] + .iter() + { + if field.is_some() { + len += 1; + } + } + + let mut map = serializer.serialize_map(Some(len))?; + if let Some(ref val) = self.uuid { + map.serialize_entry("uuid", val)?; + } + if let Some(ref val) = self.detected_mime_type { + map.serialize_entry("detected_mime_type", val)?; + } + if let Some(ref val) = self.original_filename { + map.serialize_entry("original_filename", val)?; + } + for (key, val) in self.metadata.iter() { + map.serialize_entry(format!("metadata[{}]", key).as_str(), val)?; + } + map.end() + } +} + +/// A date range. At least one of the bounds MUST be set. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct DateRange { + /// Greater than. + #[serde(skip_serializing_if = "Option::is_none")] + pub gt: Option, + /// Greater than or equal. + #[serde(skip_serializing_if = "Option::is_none")] + pub gte: Option, + /// Less than. + #[serde(skip_serializing_if = "Option::is_none")] + pub lt: Option, + /// Less than or equal. + #[serde(skip_serializing_if = "Option::is_none")] + pub lte: Option, +} + +/// A file size range in bytes. At least one of the bounds MUST be set. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct SizeRange { + /// Greater than. + #[serde(skip_serializing_if = "Option::is_none")] + pub gt: Option, + /// Greater than or equal. + #[serde(skip_serializing_if = "Option::is_none")] + pub gte: Option, + /// Less than. + #[serde(skip_serializing_if = "Option::is_none")] + pub lt: Option, + /// Less than or equal. + #[serde(skip_serializing_if = "Option::is_none")] + pub lte: Option, +} + +/// Tag based search criteria. At least one of the fields MUST be set. +#[derive(Debug, Default, PartialEq, Eq, Serialize)] +pub struct TagsFilter { + /// File has at least one of these tags. + #[serde(skip_serializing_if = "Option::is_none")] + pub any: Option>, + /// File has all of these tags. + #[serde(skip_serializing_if = "Option::is_none")] + pub all: Option>, + /// File has none of these tags. + #[serde(skip_serializing_if = "Option::is_none")] + pub none: Option>, +} + +/// Specifies the way found files are sorted. +/// +/// Sorting by size is available here, unlike in [`Ordering`] for the file list: +/// the limitation there comes from cursor based pagination, which the search +/// does not use. +#[non_exhaustive] #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] -pub enum ToStore { - /// True - #[serde(rename = "true")] - True, - /// False - #[serde(rename = "false")] - False, +pub enum Sort { + /// "score" + #[serde(rename = "score")] + Score, + /// "-score" + #[serde(rename = "-score")] + ScoreNeg, + /// "size" + #[serde(rename = "size")] + Size, + /// "-size" + #[serde(rename = "-size")] + SizeNeg, + /// "datetime_uploaded" + #[serde(rename = "datetime_uploaded")] + DatetimeUploaded, + /// "-datetime_uploaded" + #[serde(rename = "-datetime_uploaded")] + DatetimeUploadedNeg, + /// "original_filename" + #[serde(rename = "original_filename")] + OriginalFilename, + /// "-original_filename" + #[serde(rename = "-original_filename")] + OriginalFilenameNeg, } -/// MUST be either true or false. true to make copied files available via public links, -/// false to reverse the behavior. +/// Holds the search results +#[derive(Debug, Deserialize)] +pub struct SearchList { + /// Actual results + pub results: Option>, + /// Next page URL, `None` when the end of the results is reached. + /// + /// Informational only: search pages cannot be fetched with + /// [`Service::get_page`] (search is a `POST` with a body). To paginate, + /// call [`Service::search`] again with an increased + /// [`SearchParams::offset`]. + pub next: Option, + /// Previous page URL, `None` when the offset is 0. Informational only, + /// see `next`. + pub previous: Option, + /// A total number of matched files. + /// + /// Approximate: the search index lags behind, and recently removed files are + /// filtered out of the results with `total` adjusted accordingly. Do not build + /// invariants like "there are exactly `ceil(total / limit)` pages" on it. + pub total: Option, + /// Number of objects per page. + pub per_page: Option, +} + +/// A single search result: a file plus the matched fragments +#[derive(Debug, Deserialize)] +pub struct SearchResult { + /// The file itself. + #[serde(flatten)] + pub info: Info, + /// Fragments of the matched values. Always present, but may be empty. + #[serde(default)] + pub highlight: Highlight, +} + +/// Fragments of the values a file was matched by +#[derive(Debug, Default, Deserialize)] +pub struct Highlight { + /// Matched fragments of the original file name. + #[serde(default)] + pub original_filename: Vec, + /// Matched fragments of the detected MIME type. + #[serde(default)] + pub detected_mime_type: Vec, + /// Matched metadata: `metadata key -> value fragment`. + #[serde(default)] + pub metadata: HashMap, +} + +/// MUST be either true or false #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] -pub enum MakePublic { +pub enum ToStore { /// True #[serde(rename = "true")] True, @@ -451,7 +882,7 @@ pub enum Pattern { #[serde(rename = "${default}")] Default, /// AutoFilename - #[serde(rename = "${filename} ${effects} ${ext}")] + #[serde(rename = "${auto_filename}")] AutoFilename, /// Effects #[serde(rename = "${effects}")] @@ -474,15 +905,22 @@ pub enum Pattern { pub struct CopyParams { /// Source is a CDN URL or just ID (UUID) of a file subjected to copy pub source: String, - /// Store parameter only applies to the Uploadcare storage and MUST - /// be either true or false. + /// Store parameter only applies to the Uploadcare storage (local copy) and + /// MUST be either true or false. The API default is false. #[serde(skip_serializing_if = "Option::is_none")] pub store: Option, - /// MakePublic is applicable to custom storage only. MUST be either true or - /// false. True to make copied files available via public links, false to - /// reverse the behavior. + /// Arbitrary metadata attached to the copy (local copy only). Same + /// constraints as the file metadata endpoints: up to 50 keys of 64 + /// characters (`a-z A-Z 0-9 _ - . :`), values up to 512 characters. Invalid + /// keys are dropped by the API with a `Warning` response header, which the + /// client logs. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option>, + /// Applicable to custom storage only (remote copy). True to make copied + /// files available via public links, false to reverse the behavior. The + /// API default is true. #[serde(skip_serializing_if = "Option::is_none")] - pub make_public: Option, + pub make_public: Option, /// Target identifies a custom storage name related to your project. /// Implies you are copying a file to a specified custom storage. Keep in /// mind you can have multiple storages associated with a single S3 @@ -518,8 +956,529 @@ pub struct RemoteCopyInfo { /// Holds batch operation response data #[derive(Debug, Deserialize)] pub struct BatchInfo { + /// Overall request status, `"ok"` even when some of the files failed — + /// per file failures are reported through `problems`. + pub status: Option, /// Map of passed files IDs and problems associated problems pub problems: Option>, /// Results describes successfully operated files pub result: Option>, } + +/// The tags of a file as returned by [`Service::tags`] +#[derive(Debug, Deserialize)] +pub struct TagsInfo { + /// The tags themselves, ordered by their first occurrence. + #[serde(default)] + pub tags: Vec, +} + +/// The outcome of a tags modification, [`Service::set_tags`] or +/// [`Service::update_tags`] +/// +/// The API normalizes tag values (lowercases, trims, deduplicates), so `added` +/// and `deleted` reflect what actually changed rather than what was sent. +#[derive(Debug, Deserialize)] +pub struct TagsUpdate { + /// The resulting set of tags. + #[serde(default)] + pub tags: Vec, + /// Tags added by this request. + #[serde(default)] + pub added: Vec, + /// Tags removed by this request. + #[serde(default)] + pub deleted: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal v0.7 file object, the fields every response is required to carry. + fn minimal_info() -> &'static str { + r#"{ + "uuid": "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", + "size": 12345, + "mime_type": "image/jpeg", + "is_image": true, + "is_ready": true, + "original_filename": "test.jpg", + "original_file_url": null, + "url": "https://api.uploadcare.com/files/1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6/", + "datetime_uploaded": "2026-08-04T10:00:00Z", + "datetime_stored": null, + "datetime_removed": null, + "variations": null, + "content_info": null, + "metadata": {} + }"# + } + + #[test] + fn info_deserializes_minimal_response() { + let info: Info = serde_json::from_str(minimal_info()).unwrap(); + + assert_eq!(info.size, Some(12345)); + assert!(info.metadata.is_empty()); + // absent, not empty: the tags feature is off for the project + assert_eq!(info.tags, None); + // appdata is only returned when explicitly asked for + assert!(info.appdata.is_none()); + } + + #[test] + fn info_size_holds_files_over_2gb() { + let json = minimal_info().replace("\"size\": 12345", "\"size\": 3221225472"); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.size, Some(3_221_225_472)); + } + + #[test] + fn info_is_image_is_three_state() { + let json = minimal_info().replace("\"is_image\": true", "\"is_image\": null"); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + // recognition has not finished yet, which is not the same as "not an image" + assert_eq!(info.is_image, None); + } + + #[test] + fn info_metadata_is_parsed() { + let json = minimal_info().replace( + "\"metadata\": {}", + "\"metadata\": {\"subsystem\": \"uploader\", \"pk\": \"17\"}", + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.metadata.len(), 2); + assert_eq!( + info.metadata.get("subsystem"), + Some(&"uploader".to_string()) + ); + } + + #[test] + fn info_tags_tell_empty_from_missing() { + let json = minimal_info().replace("\"metadata\": {}", "\"metadata\": {}, \"tags\": []"); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.tags, Some(vec![])); + } + + #[test] + fn content_info_video_streams_are_lists() { + let json = minimal_info().replace( + "\"content_info\": null", + r#""content_info": { + "mime": {"mime": "video/mp4", "type": "video", "subtype": "mp4"}, + "video": { + "format": "MP4", + "duration": 10000, + "bitrate": 1000, + "video": [ + {"width": 1920, "height": 1080, "frame_rate": 30, "bitrate": 2000, "codec": "h264"} + ], + "audio": [ + {"bitrate": 128, "codec": "aac", "sample_rate": 44100, "channels": 2, "profile": null} + ] + } + }"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let content_info = info.content_info.unwrap(); + assert_eq!( + content_info.mime.unwrap().mime_type, + Some("video".to_string()) + ); + + let video = content_info.video.unwrap(); + assert_eq!(video.duration, Some(10000)); + assert_eq!(video.video.len(), 1); + assert_eq!(video.video[0].frame_rate, Some(30.0)); + // a number in v0.7, was a string in the v0.6 video_info + assert_eq!(video.audio[0].channels, Some(2)); + } + + #[test] + fn content_info_frame_rate_may_be_fractional() { + // NTSC video: the schema declares frame_rate a double for a reason + let json = minimal_info().replace( + "\"content_info\": null", + r#""content_info": { + "video": { + "format": "MP4", + "video": [{"width": 720, "height": 480, "frame_rate": 29.97, "codec": "h264"}], + "audio": [] + } + }"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let video = info.content_info.unwrap().video.unwrap(); + assert_eq!(video.video[0].frame_rate, Some(29.97)); + } + + #[test] + fn content_info_video_streams_may_be_empty() { + // an audio file: no video streams at all + let json = minimal_info().replace( + "\"content_info\": null", + r#""content_info": { + "video": { + "format": "MP3", + "duration": null, + "bitrate": null, + "video": [], + "audio": [{"bitrate": 128, "codec": "mp3", "sample_rate": 44100, "channels": 2}] + } + }"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let video = info.content_info.unwrap().video.unwrap(); + assert!(video.video.is_empty()); + assert_eq!(video.duration, None); + assert_eq!(video.bitrate, None); + } + + #[test] + fn content_info_ignores_unknown_keys() { + // the content detector keeps growing, unknown keys must not break parsing + let json = minimal_info().replace( + "\"content_info\": null", + r#""content_info": {"something_new": {"a": 1}}"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let content_info = info.content_info.unwrap(); + assert!(content_info.mime.is_none()); + assert!(content_info.image.is_none()); + assert!(content_info.video.is_none()); + } + + #[test] + fn appdata_keeps_application_output_opaque() { + let json = minimal_info().replace( + "\"metadata\": {}", + r#""metadata": {}, "appdata": { + "uc_clamav_virus_scan": { + "data": {"infected": false}, + "version": "0.104.2", + "datetime_created": "2026-08-04T10:00:00Z", + "datetime_updated": "2026-08-04T10:00:00Z" + }, + "remove_bg": { + "data": {"foreground_type": "person"}, + "version": null, + "datetime_created": "2026-08-04T10:00:00Z", + "datetime_updated": "2026-08-04T10:00:00Z" + } + }"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let appdata = info.appdata.unwrap(); + assert_eq!(appdata.len(), 2); + + let scan = appdata.get("uc_clamav_virus_scan").unwrap(); + assert_eq!(scan.version, Some("0.104.2".to_string())); + assert_eq!(scan.data["infected"], serde_json::json!(false)); + // version is nullable for applications that never recorded one + assert_eq!(appdata.get("remove_bg").unwrap().version, None); + } + + #[test] + fn list_params_query_defaults() { + let params = ListParams { + removed: None, + stored: None, + limit: None, + ordering: None, + from: None, + include: None, + }; + + // nothing is sent, the documented server side defaults apply + assert_eq!(params.into_query(), ""); + } + + #[test] + fn list_params_query_full() { + let params = ListParams { + removed: Some(Filter::True), + stored: Some(Filter::True), + limit: Some(10), + ordering: Some(Ordering::DatetimeUploadedNeg), + from: Some("2026-08-04T10:00:00+03:00".to_string()), + include: Some(Include::Appdata), + }; + + // the `+` of the timezone offset must be percent-encoded, otherwise it + // reaches the server as a space + assert_eq!( + params.into_query(), + "removed=true&stored=true&limit=10&ordering=-datetime_uploaded\ + &from=2026-08-04T10%3A00%3A00%2B03%3A00&include=appdata", + ); + } + + #[test] + fn list_params_query_all_filter() { + let params = ListParams { + removed: Some(Filter::All), + stored: Some(Filter::All), + limit: None, + ordering: None, + from: None, + include: None, + }; + + // `all` is not a documented parameter value: the filter is simply + // not sent + assert_eq!(params.into_query(), ""); + } + + #[test] + fn metadata_key_is_validated() { + assert!(validate_metadata_key("subsystem").is_ok()); + assert!(validate_metadata_key("a-b.c:d_9").is_ok()); + + // only latin letters, digits and `_-.:` are allowed; anything else is + // ignored by the API, so it is rejected before it reaches the URL + assert!(validate_metadata_key("отдел").is_err()); + assert!(validate_metadata_key("").is_err()); + assert!(validate_metadata_key("a/b").is_err()); + assert!(validate_metadata_key("x".repeat(65).as_str()).is_err()); + } + + #[test] + fn metadata_key_rejects_dot_segments() { + // `..` is within the allowed charset, but `Url::parse` normalizes + // `/files/{uuid}/metadata/../` into `/files/{uuid}/`, turning a metadata + // delete into a delete of the file itself + assert!(validate_metadata_key(".").is_err()); + assert!(validate_metadata_key("..").is_err()); + + // a dot inside a key is still fine + assert!(validate_metadata_key(".hidden").is_ok()); + assert!(validate_metadata_key("a..b").is_ok()); + } + + #[test] + fn info_metadata_may_be_null() { + // REST v0.7 always sends an object, a webhook delivery may send null + let json = minimal_info().replace("\"metadata\": {}", "\"metadata\": null"); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert!(info.metadata.is_empty()); + } + + #[test] + fn content_info_video_numbers_may_be_fractional() { + // documented as integers, but ffprobe derived values are not always whole + let json = minimal_info().replace( + "\"content_info\": null", + r#""content_info": { + "video": { + "format": "MP4", + "duration": 22990.5, + "bitrate": 8000.2, + "video": [], + "audio": [] + } + }"#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + let video = info.content_info.unwrap().video.unwrap(); + assert_eq!(video.duration, Some(22991)); + assert_eq!(video.bitrate, Some(8000)); + } + + #[test] + fn tags_update_deserializes() { + let update: TagsUpdate = serde_json::from_str( + r#"{"tags": ["invoice", "2026"], "added": ["2026"], "deleted": ["draft"]}"#, + ) + .unwrap(); + + assert_eq!(update.tags, vec!["invoice", "2026"]); + assert_eq!(update.added, vec!["2026"]); + assert_eq!(update.deleted, vec!["draft"]); + } + + #[test] + fn search_query_serializes_only_what_is_set() { + let query = SearchQuery { + query: Some("invoice".to_string()), + sort: Some(vec![Sort::ScoreNeg, Sort::SizeNeg]), + ..Default::default() + }; + + assert_eq!( + serde_json::to_value(&query).unwrap(), + serde_json::json!({"query": "invoice", "sort": ["-score", "-size"]}), + ); + } + + #[test] + fn search_query_is_image_serializes_as_json_boolean() { + // strings "true"/"false" are rejected by the API with a 400 + let query = SearchQuery { + is_image: Some(true), + ..Default::default() + }; + + assert_eq!( + serde_json::to_value(&query).unwrap(), + serde_json::json!({"is_image": true}), + ); + } + + #[test] + fn search_query_exact_serializes_metadata_keys() { + let mut metadata = HashMap::new(); + metadata.insert("subsystem".to_string(), vec!["uploader".to_string()]); + + let query = SearchQuery { + exact: Some(Exact { + uuid: Some(vec!["1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6".to_string()]), + metadata, + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!( + serde_json::to_value(&query).unwrap(), + serde_json::json!({"exact": { + "uuid": ["1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6"], + "metadata[subsystem]": ["uploader"], + }}), + ); + } + + #[test] + fn search_query_ranges_are_serialized() { + let query = SearchQuery { + size: Some(SizeRange { + gte: Some(1024), + lt: Some(3_221_225_472), + ..Default::default() + }), + datetime_uploaded: Some(DateRange { + gt: Some("2026-08-01T00:00:00Z".to_string()), + ..Default::default() + }), + tags: Some(TagsFilter { + any: Some(vec!["invoice".to_string()]), + ..Default::default() + }), + ..Default::default() + }; + + assert_eq!( + serde_json::to_value(&query).unwrap(), + serde_json::json!({ + "size": {"gte": 1024, "lt": 3221225472i64}, + "datetime_uploaded": {"gt": "2026-08-01T00:00:00Z"}, + "tags": {"any": ["invoice"]}, + }), + ); + } + + #[test] + fn search_query_without_criteria_serializes_empty() { + // the API answers 400 for this, the library passes it through rather than + // duplicating the validation + assert_eq!( + serde_json::to_value(SearchQuery::default()).unwrap(), + serde_json::json!({}), + ); + } + + #[test] + fn search_params_pagination_query() { + let params = SearchParams { + query: SearchQuery::default(), + limit: Some(50), + offset: Some(100), + include: Some(Include::Appdata), + }; + + assert_eq!( + params.pagination_query(), + Some("limit=50&offset=100&include=appdata".to_string()), + ); + + // nothing to put into the query string, the API defaults apply + assert_eq!(SearchParams::default().pagination_query(), None); + } + + #[test] + fn search_result_holds_file_and_highlight() { + let json = format!( + r#"{{"next": null, "previous": null, "total": 1, "per_page": 20, + "results": [{{"highlight": {{ + "original_filename": ["invoice.pdf"], + "metadata": {{"subsystem": "uploader"}} + }}, {}}}]}}"#, + // the file itself is flattened into the same object + minimal_info().trim_start_matches('{').trim_end_matches('}'), + ); + let found: SearchList = serde_json::from_str(json.as_str()).unwrap(); + + let results = found.results.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].info.uuid, "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6",); + assert_eq!( + results[0].highlight.original_filename, + vec!["invoice.pdf".to_string()], + ); + assert_eq!( + results[0].highlight.metadata.get("subsystem"), + Some(&"uploader".to_string()), + ); + // not matched by this field + assert!(results[0].highlight.detected_mime_type.is_empty()); + } + + #[test] + fn search_result_accepts_empty_highlight() { + let json = format!( + r#"{{"results": [{{"highlight": {{}}, {}}}]}}"#, + minimal_info().trim_start_matches('{').trim_end_matches('}'), + ); + let found: SearchList = serde_json::from_str(json.as_str()).unwrap(); + + let results = found.results.unwrap(); + assert!(results[0].highlight.original_filename.is_empty()); + assert!(results[0].highlight.metadata.is_empty()); + } + + #[test] + fn list_deserializes_totals() { + let json = r#"{ + "next": null, + "previous": null, + "total": 3, + "totals": {"removed": 1, "stored": 2, "unstored": 0}, + "per_page": 100, + "results": [] + }"#; + let list: List = serde_json::from_str(json).unwrap(); + + assert_eq!(list.total, Some(3)); + assert_eq!( + list.totals, + Some(Totals { + removed: Some(1), + stored: Some(2), + unstored: Some(0), + }), + ); + } +} diff --git a/src/group.rs b/src/group.rs index 02a3509..024152c 100644 --- a/src/group.rs +++ b/src/group.rs @@ -1,4 +1,4 @@ -//! Holds all primitives and logic related file entity. +//! Holds all primitives and logic around the group resource. //! //! Individual files on Uploadcare can be joined into groups. Those can be used //! to better organize your workflow. Technically, groups are ordered lists of @@ -19,7 +19,8 @@ use std::fmt::{self, Debug, Display}; use reqwest::{Method, Url}; use serde::Deserialize; -use crate::ucare::{rest::Client, IntoUrlQuery, Result}; +use crate::file; +use crate::ucare::{encode_query_value, rest::Client, IntoUrlQuery, Result}; /// Service is used to make calls to group API. pub struct Service<'a> { @@ -27,12 +28,12 @@ pub struct Service<'a> { } /// creates an instance of the group service -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } impl Service<'_> { - /// Acquires some file specific info + /// Acquires group specific info, including the list of files in it pub fn info(&self, group_id: &str) -> Result { self.client.call::( Method::GET, @@ -63,13 +64,13 @@ impl Service<'_> { /// } /// /// for group in groups.iter() { - /// println!("group: {}", group); + /// println!("group: {:?}", group); /// } /// ``` pub fn list(&self, params: ListParams) -> Result { self.client.call::( Method::GET, - format!("/groups/"), + "/groups/".to_string(), Some(params), None, ) @@ -81,11 +82,14 @@ impl Service<'_> { self.client.call_url::(Method::GET, url, None) } - /// Marks all files in group as stored - pub fn store(&self, group_id: &str) -> Result { - self.client.call::( - Method::PUT, - format!("/groups/{}/storage/", group_id), + /// Removes a group by its id. Available since APIv0.7 only. + /// + /// The files in the group are not affected, only the group itself is + /// removed. + pub fn delete(&self, group_id: &str) -> Result<()> { + self.client.call::( + Method::DELETE, + format!("/groups/{}/", group_id), None, None, ) @@ -98,13 +102,19 @@ pub struct Info { /// group identifier pub id: String, /// date and time when a group was created - pub datetime_created: Option, - /// date and time when a group was stored - pub datetime_stored: Option, + pub datetime_created: String, /// number of files in a group pub files_count: i32, /// public CDN URL for a group pub cdn_url: String, + /// API resource URL for the group + pub url: Option, + /// The files in the group, in their original order. + /// + /// Only returned by [`Service::info`]; list responses carry no file lists. + /// An element is `None` when the corresponding file has been removed. + #[serde(default)] + pub files: Option>>, } /// Holds all possible params for for the list method @@ -142,30 +152,19 @@ impl Display for Ordering { impl IntoUrlQuery for ListParams { fn into_query(self) -> String { - let mut q = String::new(); - - q.push_str("limit="); + // unset parameters are not sent, the documented server defaults apply + let mut parts: Vec = Vec::new(); if let Some(val) = self.limit { - q.push_str(val.to_string().as_str()); - } else { - q.push_str("100"); + parts.push(format!("limit={}", val)); } - q.push('&'); - - q.push_str("ordering="); if let Some(val) = self.ordering { - q.push_str(val.to_string().as_str()); - } else { - q.push_str(Ordering::CreatedAtAsc.to_string().as_str()); + parts.push(format!("ordering={}", val)); } - - if let Some(val) = self.from { - q.push('&'); - q.push_str("from="); - q.push_str(val.as_str()); + if let Some(ref val) = self.from { + parts.push(format!("from={}", encode_query_value(val))); } - q + parts.join("&") } } @@ -179,7 +178,106 @@ pub struct List { /// Previous page URL. pub previous: Option, /// A total number of objects of the queried type. - pub total: Option, + pub total: Option, /// Number of objects per page. - pub per_page: Option, + pub per_page: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_deserializes_without_datetime_stored() { + // v0.7 dropped datetime_stored together with the ability to mark a group + // as stored + let json = r#"{ + "id": "badfc9f7-f88f-4921-9cc0-22e2c08aa2da~12", + "datetime_created": "2026-08-04T10:00:00Z", + "files_count": 12, + "cdn_url": "https://ucarecdn.com/badfc9f7-f88f-4921-9cc0-22e2c08aa2da~12/", + "url": "https://api.uploadcare.com/groups/badfc9f7-f88f-4921-9cc0-22e2c08aa2da~12/" + }"#; + let info: Info = serde_json::from_str(json).unwrap(); + + assert_eq!(info.files_count, 12); + assert_eq!(info.datetime_created, "2026-08-04T10:00:00Z"); + assert_eq!( + info.url, + Some("https://api.uploadcare.com/groups/badfc9f7-f88f-4921-9cc0-22e2c08aa2da~12/".to_string()), + ); + // list responses carry no `files` + assert!(info.files.is_none()); + } + + #[test] + fn info_files_may_hold_removed_placeholders() { + // the files array of the info endpoint contains null for removed files + let json = r#"{ + "id": "badfc9f7-f88f-4921-9cc0-22e2c08aa2da~2", + "datetime_created": "2026-08-04T10:00:00Z", + "files_count": 2, + "cdn_url": "https://ucarecdn.com/badfc9f7-f88f-4921-9cc0-22e2c08aa2da~2/", + "files": [ + null, + { + "uuid": "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", + "size": 12345, + "is_image": false, + "is_ready": true, + "metadata": {} + } + ] + }"#; + let info: Info = serde_json::from_str(json).unwrap(); + + let files = info.files.unwrap(); + assert_eq!(files.len(), 2); + assert!(files[0].is_none()); + assert_eq!( + files[1].as_ref().unwrap().uuid, + "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", + ); + } + + #[test] + fn list_totals_are_integers() { + let json = r#"{ + "next": null, + "previous": null, + "total": 42, + "per_page": 100, + "results": [] + }"#; + let list: List = serde_json::from_str(json).unwrap(); + + assert_eq!(list.total, Some(42)); + assert_eq!(list.per_page, Some(100)); + } + + #[test] + fn list_params_query_defaults() { + let params = ListParams { + limit: None, + ordering: None, + from: None, + }; + + // nothing is sent, the documented server side defaults apply + assert_eq!(params.into_query(), ""); + } + + #[test] + fn list_params_query_full() { + let params = ListParams { + limit: Some(10), + ordering: Some(Ordering::CreatedAtDesc), + from: Some("2026-08-04T10:00:00+03:00".to_string()), + }; + + assert_eq!( + params.into_query(), + "limit=10&ordering=-datetime_created&from=2026-08-04T10%3A00%3A00%2B03%3A00", + ); + } } diff --git a/src/lib.rs b/src/lib.rs index cf1059a..8f0db44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,7 +23,7 @@ //! }; //! let config = ucare::RestConfig { //! sign_based_auth: true, -//! api_version: ucare::RestApiVersion::V05, +//! api_version: ucare::RestApiVersion::V07, //! }; //! //! let rest_client = ucare::RestClient::new(config, creds).unwrap(); @@ -32,17 +32,21 @@ //! //! // getting a list of files //! let list_params = file::ListParams{ -//! removed: Some(true), -//! stored: Some(true), +//! removed: Some(file::Filter::False), +//! stored: Some(file::Filter::All), //! limit: Some(10), -//! ordering: Some(file::Ordering::Size), +//! ordering: Some(file::Ordering::DatetimeUploaded), //! from: None, +//! include: None, //! }; //! let list = file_svc.list(list_params).unwrap(); //! //! // getting file info //! let file_id = &list.results.unwrap()[0].uuid; -//! let file_info = file_svc.info(&file_id).unwrap(); +//! let file_info = file_svc.info(&file_id, None).unwrap(); +//! +//! // the same, with the appdata field populated +//! let file_info = file_svc.info(&file_id, Some(file::Include::Appdata)).unwrap(); //! //! // store file by its id //! file_svc.store(&file_id).unwrap(); @@ -67,6 +71,8 @@ pub use crate::ucare::rest::{ #[cfg(feature = "upload")] pub use crate::ucare::upload::{Client as UploadClient, Config as UploadConfig}; +#[cfg(feature = "rest")] +pub mod addons; #[cfg(feature = "rest")] pub mod conversion; #[cfg(feature = "rest")] @@ -81,4 +87,6 @@ pub mod webhook; #[cfg(feature = "upload")] pub mod upload; +pub mod types; + pub use crate::ucare::{ApiCreds, ErrValue, Error, Result}; diff --git a/src/project.rs b/src/project.rs index f2d37b6..5f3fc35 100644 --- a/src/project.rs +++ b/src/project.rs @@ -7,13 +7,13 @@ use serde::Deserialize; use crate::ucare::{rest::Client, Result}; -/// Service is used to make calls to webhook API. +/// Service is used to make calls to project API. pub struct Service<'a> { client: &'a Client, } -/// creates an instance of the webhook service -pub fn new_svc(client: &Client) -> Service { +/// creates an instance of the project service +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } @@ -21,7 +21,7 @@ impl Service<'_> { /// Getting info about account project. pub fn info(&self) -> Result { self.client - .call::(Method::GET, format!("/project/"), None, None) + .call::(Method::GET, "/project/".to_string(), None, None) } } @@ -34,6 +34,9 @@ pub struct Info { pub pub_key: String, /// Project collaborators. pub collaborators: Option>, + /// Whether uploads are automatically stored (the project level auto-store + /// setting). + pub autostore_enabled: Option, } /// Collaborator information diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..3204484 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,216 @@ +//! Types shared between the REST and the Upload API. +//! +//! The two APIs are versioned independently, so most of the response shapes +//! live in their own modules. What ends up here is only what both of them +//! return in exactly the same form. + +use serde::Deserialize; + +/// Recognized information about the file content. +/// +/// REST APIv0.7 returns it as `content_info` of the file object, the Upload API — +/// as `content_info` of its file info responses; the shape is the same. +/// +/// All three of the fields are optional: a non media file has neither `image` nor +/// `video`, and files uploaded before the field was introduced may have no `mime`. +#[derive(Debug, Deserialize)] +pub struct ContentInfo { + /// Detected MIME type. + pub mime: Option, + /// Image metadata. + pub image: Option, + /// Video metadata. + pub video: Option, +} + +/// Detected MIME type, split into parts +#[derive(Debug, PartialEq, Eq, Deserialize)] +pub struct MimeInfo { + /// Full MIME type, `image/jpeg` for example. + pub mime: Option, + /// Type part, `image` for example. + #[serde(rename = "type")] + pub mime_type: Option, + /// Subtype part, `jpeg` for example. + pub subtype: Option, +} + +/// Video related information +/// +/// Note the difference from the APIv0.6 `video_info` (`upload::VideoInfo`), +/// which uses the old shape: `video` and `audio` are lists of streams here, and +/// `duration` and `bitrate` are nullable. +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoInfo { + /// Video format (MP4 for example). + pub format: Option, + /// Video duration in milliseconds. + /// + /// Documented as an integer, but the value is ffprobe derived and a + /// fractional one has been observed, so both parse; see + /// [`crate::ucare::de_lenient_int`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub duration: Option, + /// Video bitrate. Same leniency as `duration`. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Video streams. Empty for files without a video stream, an audio file for example. + #[serde(default)] + pub video: Vec, + /// Audio streams. Empty when the file has no sound. + #[serde(default)] + pub audio: Vec, +} + +/// A single video stream of a video file +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoStream { + /// Video stream image height. + pub height: Option, + /// Video stream image width. + pub width: Option, + /// Video stream frame rate. + /// + /// A double per the documented schema: fractional NTSC style rates + /// (`29.97`) are common, do not assume a whole number. + pub frame_rate: Option, + /// Video stream bitrate. Same leniency as [`VideoInfo::duration`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Video stream codec. + pub codec: Option, +} + +/// A single audio stream of a video file +#[derive(Debug, PartialEq, Eq, Deserialize)] +pub struct AudioStream { + /// Audio stream number of channels. + /// + /// Same caveat as [`crate::upload::VideoInfoAudio::channels`]: the schema + /// documents an integer, a string (`"2"`) is what actually arrives. Both + /// parse. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub channels: Option, + /// Audio stream bitrate. Same leniency as [`VideoInfo::duration`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Audio stream codec. + pub codec: Option, + /// Audio stream sample rate. + pub sample_rate: Option, + /// Audio stream profile. + pub profile: Option, +} + +/// ImageInfo holds image-specific information. +/// +/// REST APIv0.7 returns it as `content_info.image` (see [`ContentInfo`]), the +/// Upload API — as `content_info.image` and the legacy `image_info` of +/// `upload::FileInfo`. The set of fields is the same everywhere. +#[derive(Debug, Deserialize)] +pub struct ImageInfo { + /// Image color mode. + pub color_mode: Option, + /// Image orientation from EXIF. + pub orientation: Option, + /// Image format. + pub format: Option, + /// Image sequence + pub sequence: Option, + /// Image height in pixels. + pub height: Option, + /// Image width in pixels. + pub width: Option, + /// Image geo location. + pub geo_location: Option, + /// Image date and time from EXIF. + pub datetime_original: Option, + /// Image DPI for two dimensions. + pub dpi: Option>, +} + +/// Image geo location +#[derive(Debug, Deserialize)] +pub struct ImageInfoGeoLocation { + /// Location latitude. + pub latitude: Option, + /// Location longitude. + pub longitude: Option, +} + +/// Image color mode. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Deserialize)] +pub enum ColorMode { + /// RGB + RGB, + /// RGBA + RGBA, + /// RGBa + RGBa, + /// RGBX + RGBX, + /// L + L, + /// LA + LA, + /// La + La, + /// P + P, + /// PA + PA, + /// CMYK + CMYK, + /// YCbCr + YCbCr, + /// HSV + HSV, + /// LAB + LAB, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn audio_stream_channels_accept_both_wire_forms() { + // the schema documents an integer, the service sends a string; a + // response carrying either has to parse + let number: AudioStream = + serde_json::from_str(r#"{"channels": 2, "codec": "aac"}"#).unwrap(); + assert_eq!(number.channels, Some(2)); + + let string: AudioStream = + serde_json::from_str(r#"{"channels": "2", "codec": "aac"}"#).unwrap(); + assert_eq!(string.channels, Some(2)); + } + + #[test] + fn audio_stream_channels_may_be_absent() { + let stream: AudioStream = serde_json::from_str(r#"{"codec": "aac"}"#).unwrap(); + + assert_eq!(stream.channels, None); + } + + #[test] + fn content_info_video_parses_a_stream_list() { + let info: ContentInfo = serde_json::from_str( + r#"{ + "mime": {"mime": "video/mp4", "type": "video", "subtype": "mp4"}, + "video": { + "format": "mp4", + "duration": 22990, + "bitrate": 8000, + "video": [{"height": 1920, "width": 1080, "frame_rate": 30.0, "codec": "h264"}], + "audio": [{"channels": "2", "codec": "aac", "sample_rate": 44100}] + } + }"#, + ) + .unwrap(); + + let video = info.video.unwrap(); + assert_eq!(video.video.len(), 1); + assert_eq!(video.audio[0].channels, Some(2)); + } +} diff --git a/src/ucare/error.rs b/src/ucare/error.rs index a2429e3..a1f628c 100644 --- a/src/ucare/error.rs +++ b/src/ucare/error.rs @@ -5,10 +5,7 @@ use std::fmt; use std::io; -use reqwest; use serde::Deserialize; -use serde_json; -use url; /// Result has Error as default value for Err value pub type Result = std::result::Result; @@ -99,12 +96,20 @@ pub enum ErrValue { Forbidden(String), /// Not found error NotFound(String), + /// Method is not supported by the endpoint + MethodNotAllowed(String), /// Invalid version header `Accept` for the endpoint NotAcceptable(String), + /// Request conflicts with the current state of the resource. Add-Ons use it to + /// report that the same application is already processing the same file. + Conflict(String), /// Payload too large PayloadTooLarge(String), /// Request was throttled TooManyRequests(i32), + /// API responded with a 5xx status. Holds the status code and the response + /// body, which is not necessarily a json payload. + ServerError(u16, String), /// Errors returned from reqwest underlying lib Reqwest(reqwest::Error), @@ -128,13 +133,18 @@ impl fmt::Display for ErrValue { ErrValue::Unauthorized(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::Forbidden(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::NotFound(ref msg) => write!(f, "{}: {}", prefix, msg), + ErrValue::MethodNotAllowed(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::NotAcceptable(ref msg) => write!(f, "{}: {}", prefix, msg), + ErrValue::Conflict(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::PayloadTooLarge(ref msg) => write!(f, "{}: {}", prefix, msg), ErrValue::TooManyRequests(ref retry_after) => write!( f, "{}: too many requests, retry after {}", prefix, retry_after ), + ErrValue::ServerError(status, ref msg) => { + write!(f, "{}: server error {}: {}", prefix, status, msg) + } ErrValue::Reqwest(ref err) => write!(f, "{}: {}", prefix, err), ErrValue::InputOutput(ref err) => write!(f, "{}: {}", prefix, err), diff --git a/src/ucare/mod.rs b/src/ucare/mod.rs index dd62749..eed2ab8 100644 --- a/src/ucare/mod.rs +++ b/src/ucare/mod.rs @@ -3,7 +3,7 @@ use std::fmt::Debug; use reqwest::Url; -use serde::Serialize; +use serde::{Deserialize, Serialize}; mod error; pub use error::{ErrValue, Error, Result}; @@ -14,7 +14,10 @@ pub mod rest; #[cfg(feature = "upload")] pub mod upload; -pub(crate) const CLIENT_VERSION: &str = "0.1"; +/// Version reported in the `X-UC-User-Agent` header. Taken from the crate +/// manifest so it never drifts away from the published version. +#[cfg(feature = "rest")] +pub(crate) const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Holds per project API credentials. /// You can find your credentials on the uploadcare dashboard. @@ -39,6 +42,7 @@ where } } +#[cfg(feature = "rest")] pub(crate) fn encode_json(params: &T) -> Result, Error> where T: ?Sized + Serialize, @@ -50,15 +54,222 @@ where } } +/// Percent-encodes a single query parameter value. +/// +/// `into_query` implementations concatenate `key=value` pairs by hand, so any +/// user supplied value (an ISO 8601 cursor with `+03:00`, a request id) must be +/// encoded here or characters like `+`, `&` and `#` change the request. +#[cfg(feature = "rest")] +pub(crate) fn encode_query_value(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +/// Deserializes an optional integer the API may send in any of the forms a +/// media metadata field is known to arrive in: a json integer, a json float or +/// a string holding either. +/// +/// The documented schema types all of these as integers, the service does not +/// always agree: the audio channel count is documented as an integer and +/// answered as `"2"`, and the ffprobe derived durations and bitrates can come +/// back fractional. Accepting every form is the only option that does not make +/// a real response fail to parse — a stricter type broke deserialization of the +/// whole file object over a single field. Fractional values are rounded to the +/// nearest integer. +/// +/// `Option` alone does not make the field optional here: `deserialize_with` +/// takes over the whole field, so a `#[serde(default)]` is required next to it +/// for a missing key to come out as `None`. +pub(crate) fn de_lenient_int<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum LenientInt { + Int(i64), + Float(f64), + Str(String), + } + + const EXPECTED: &str = "an integer, a number, or a string holding one"; + + match Option::::deserialize(deserializer)? { + None => Ok(None), + Some(LenientInt::Int(value)) => Ok(Some(value)), + Some(LenientInt::Float(value)) => float_to_int(value).map(Some).ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Float(value), &EXPECTED) + }), + Some(LenientInt::Str(raw)) => raw + .parse::() + .ok() + .or_else(|| raw.parse::().ok().and_then(float_to_int)) + .map(Some) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Str(&raw), &EXPECTED) + }), + } +} + +/// Rounds a json number to an `i64`, `None` when it does not fit into one. +fn float_to_int(value: f64) -> Option { + let rounded = value.round(); + if rounded.is_finite() && rounded >= i64::MIN as f64 && rounded <= i64::MAX as f64 { + Some(rounded as i64) + } else { + None + } +} + +/// Deserializes a map that the API documents as always present, tolerating an +/// explicit `null`. +/// +/// `#[serde(default)]` alone only covers a missing key: a `"metadata": null` — +/// which webhook deliveries are documented to carry — fails the whole response +/// with "invalid type: null, expected a map". Both forms mean "nothing here". +pub(crate) fn de_null_as_default<'de, D, T>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, + T: Deserialize<'de> + Default, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + +/// Percent-encodes a caller supplied value that goes into the request path. +/// +/// Path segments are interpolated into the url by hand, so a value holding `/`, +/// `?` or `#` would rewrite the path or the query of the request. Everything +/// outside the unreserved set of RFC 3986 is encoded here. +/// +/// Dot segments cannot be encoded away — `Url::parse` normalizes `..` and its +/// percent-encoded spellings alike, turning `/files/{uuid}/metadata/../` into +/// `/files/{uuid}/` — so they are rejected instead. +#[cfg(feature = "rest")] +pub(crate) fn encode_path_segment(value: &str) -> Result { + if is_dot_segment(value) { + return Err(Error::with_value(ErrValue::BadRequest(format!( + "invalid path segment {:?}: `.` and `..` would change the request path", + value, + )))); + } + + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(byte as char) + } + _ => encoded.push_str(format!("%{:02X}", byte).as_str()), + } + } + Ok(encoded) +} + +/// Whether a value is a url path segment with a special meaning: `.` or `..`, +/// in any of their percent-encoded spellings. +#[cfg(feature = "rest")] +pub(crate) fn is_dot_segment(value: &str) -> bool { + let normalized = value.to_ascii_lowercase().replace("%2e", "."); + normalized == "." || normalized == ".." +} + pub(crate) fn encode_url(base: &str, path: &str, params: Option) -> Result where T: IntoUrlQuery, { let mut u = base.to_string() + path; if let Some(data) = params { - u = u + "?" + data.into_query().as_str(); + let query = data.into_query(); + if !query.is_empty() { + u = u + "?" + query.as_str(); + } } let url = Url::parse(u.as_str())?; Ok(url) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_segment_is_percent_encoded() { + assert_eq!(encode_path_segment("remove_bg").unwrap(), "remove_bg"); + assert_eq!( + encode_path_segment("uc_clamav_virus_scan").unwrap(), + "uc_clamav_virus_scan", + ); + // a value like this would otherwise rewrite the path and the query + assert_eq!(encode_path_segment("app?x=1").unwrap(), "app%3Fx%3D1"); + assert_eq!(encode_path_segment("a/b#c").unwrap(), "a%2Fb%23c"); + assert_eq!( + encode_path_segment("привет").unwrap(), + "%D0%BF%D1%80%D0%B8%D0%B2%D0%B5%D1%82" + ); + } + + #[test] + fn path_segment_rejects_dot_segments() { + // percent-encoding does not help here: Url::parse normalizes `%2e%2e` + // just like `..`, so the segment has to be refused outright + assert!(encode_path_segment(".").is_err()); + assert!(encode_path_segment("..").is_err()); + assert!(encode_path_segment("%2e%2E").is_err()); + + assert!(encode_path_segment("..a").is_ok()); + } + + #[test] + fn dot_segments_are_normalized_by_the_url_parser() { + // what the rejection above protects from + let url = encode_url::( + "https://api.uploadcare.com", + "/files/1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6/metadata/../", + None, + ) + .unwrap(); + + assert_eq!(url.path(), "/files/1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6/"); + } + + #[test] + fn lenient_int_accepts_every_observed_form() { + #[derive(serde::Deserialize)] + struct Holder { + #[serde(default, deserialize_with = "de_lenient_int")] + value: Option, + } + + let parse = |json: &str| serde_json::from_str::(json).map(|h| h.value); + + assert_eq!(parse(r#"{"value": 22990}"#).unwrap(), Some(22990)); + assert_eq!(parse(r#"{"value": "22990"}"#).unwrap(), Some(22990)); + // fractional ffprobe values are rounded rather than rejected + assert_eq!(parse(r#"{"value": 22990.5}"#).unwrap(), Some(22991)); + assert_eq!(parse(r#"{"value": "22990.4"}"#).unwrap(), Some(22990)); + assert_eq!(parse(r#"{"value": null}"#).unwrap(), None); + assert_eq!(parse("{}").unwrap(), None); + + assert!(parse(r#"{"value": "stereo"}"#).is_err()); + } + + #[test] + fn null_map_comes_out_empty() { + use std::collections::HashMap; + + #[derive(serde::Deserialize)] + struct Holder { + #[serde(default, deserialize_with = "de_null_as_default")] + metadata: HashMap, + } + + let null: Holder = serde_json::from_str(r#"{"metadata": null}"#).unwrap(); + assert!(null.metadata.is_empty()); + + let missing: Holder = serde_json::from_str("{}").unwrap(); + assert!(missing.metadata.is_empty()); + + let present: Holder = serde_json::from_str(r#"{"metadata": {"a": "b"}}"#).unwrap(); + assert_eq!(present.metadata.get("a"), Some(&"b".to_string())); + } +} diff --git a/src/ucare/rest/auth.rs b/src/ucare/rest/auth.rs index cbb1a78..efcd15d 100644 --- a/src/ucare/rest/auth.rs +++ b/src/ucare/rest/auth.rs @@ -10,7 +10,7 @@ use crate::ucare::ApiCreds; const AUTH_HEADER_KEY: &str = "Authorization"; const SIMPLE_AUTH_SCHEME: &str = "Uploadcare.Simple"; const SIGN_BASED_AUTH_SCHEME: &str = "Uploadcare"; -pub const DATE_HEADER_FORMAT: &str = "%a, %d %h %G %T %Z"; +pub const DATE_HEADER_FORMAT: &str = "%a, %d %b %Y %T GMT"; pub fn simple(creds: ApiCreds) -> impl Fn(&mut Request) { move |req: &mut Request| { @@ -19,7 +19,7 @@ pub fn simple(creds: ApiCreds) -> impl Fn(&mut Request) { SIMPLE_AUTH_SCHEME, creds.pub_key, creds.secret_key ); - debug!("preparing simple auth param: {}", auth); + debug!("preparing simple auth param with pubkey: {}", creds.pub_key); req.headers_mut() .insert(AUTH_HEADER_KEY, auth.parse().unwrap()); @@ -79,7 +79,7 @@ pub fn sign_based(creds: ApiCreds) -> impl Fn(&mut Request) { #[cfg(test)] mod tests { use super::*; - use chrono::{DateTime, NaiveDateTime, Utc}; + use chrono::DateTime; use reqwest::{blocking::Request, Method, Url}; fn setup_req() -> Request { @@ -117,10 +117,10 @@ mod tests { let mut req = setup_req(); let headers = req.headers_mut(); - let now = DateTime::::from_utc(NaiveDateTime::from_timestamp(1541423681, 0), Utc) + let now = DateTime::from_timestamp(1541423681, 0) + .unwrap() .format(DATE_HEADER_FORMAT) - .to_string() - .replace("UTC", "GMT"); + .to_string(); headers.insert("Date", now.parse().unwrap()); headers.insert("Content-Type", "application/json".parse().unwrap()); diff --git a/src/ucare/rest/mod.rs b/src/ucare/rest/mod.rs index e0e04ee..3e56254 100644 --- a/src/ucare/rest/mod.rs +++ b/src/ucare/rest/mod.rs @@ -3,9 +3,9 @@ use std::fmt::{self, Debug}; use chrono::Utc; -use log::debug; +use log::{debug, warn}; use reqwest::{ - blocking::{Body, Client as http_client, ClientBuilder, Request}, + blocking::{Body, Client as http_client, ClientBuilder, Request, Response}, header, Method, StatusCode, Url, }; use serde::Deserialize; @@ -16,21 +16,28 @@ mod auth; const USER_AGENT_PREFIX: &str = "UploadcareRust"; const API_URL: &str = "https://api.uploadcare.com"; +/// Error response bodies longer than that are cut before being put into an `Error`. +const MAX_ERROR_BODY_LEN: usize = 512; +/// Reported by `ErrValue::TooManyRequests` when the `Retry-After` header of a +/// `429` is missing or not a plain number of seconds. Same value as the Upload +/// API client uses. +const DEFAULT_RETRY_AFTER_SECS: i32 = 30; /// Available API versions for client to specify when making requests. +/// +/// Non exhaustive on purpose: API versions come and go, and matching on this +/// enum downstream must not break when the next one is added. #[derive(Debug)] +#[non_exhaustive] pub enum ApiVersion { - /// API version v0.5 - V05, - /// API version v0.6 (prefered) - V06, + /// API version v0.7 + V07, } impl fmt::Display for ApiVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { - ApiVersion::V05 => write!(f, "v0.5"), - ApiVersion::V06 => write!(f, "v0.6"), + ApiVersion::V07 => write!(f, "v0.7"), } } } @@ -70,7 +77,7 @@ impl Client { headers.insert( header::ACCEPT, header::HeaderValue::from_str( - format!("application/vnd.uploadcare-{}+json", &config.api_version).as_str(), + format!("application/vnd.uploadcare-{}+json", config.api_version).as_str(), ) .unwrap(), ); @@ -80,7 +87,7 @@ impl Client { header::HeaderValue::from_str( format!( "{}/{}/{}", - USER_AGENT_PREFIX, CLIENT_VERSION, &creds.pub_key + USER_AGENT_PREFIX, CLIENT_VERSION, creds.pub_key ) .as_str(), ) @@ -137,10 +144,7 @@ impl Client { .request(method, url) .header( header::DATE, - Utc::now() - .format(auth::DATE_HEADER_FORMAT) - .to_string() - .replace("UTC", "GMT"), + Utc::now().format(auth::DATE_HEADER_FORMAT).to_string(), ) .header( header::CONTENT_TYPE, @@ -157,28 +161,169 @@ impl Client { let res = self.client.execute(req)?; debug!("received response: {:?}", res); + log_warnings(&res); + match res.status() { - StatusCode::BAD_REQUEST => Err(Error::with_value(ErrValue::BadRequest( - res.json::()?.detail(), - ))), + StatusCode::BAD_REQUEST => Err(Error::with_value(ErrValue::BadRequest(error_detail( + res, + "bad request", + )))), StatusCode::UNAUTHORIZED => Err(Error::with_value(ErrValue::Unauthorized( - res.json::()?.detail(), + error_detail(res, "unauthorized"), + ))), + StatusCode::FORBIDDEN => Err(Error::with_value(ErrValue::Forbidden(error_detail( + res, + "forbidden", + )))), + StatusCode::NOT_FOUND => Err(Error::with_value(ErrValue::NotFound(error_detail( + res, + "not found", + )))), + StatusCode::METHOD_NOT_ALLOWED => Err(Error::with_value(ErrValue::MethodNotAllowed( + error_detail(res, "method not allowed"), ))), StatusCode::NOT_ACCEPTABLE => Err(Error::with_value(ErrValue::NotAcceptable( - res.json::()?.detail(), + error_detail(res, "not acceptable"), + ))), + StatusCode::CONFLICT => Err(Error::with_value(ErrValue::Conflict(error_detail( + res, "conflict", + )))), + StatusCode::PAYLOAD_TOO_LARGE => Err(Error::with_value(ErrValue::PayloadTooLarge( + error_detail(res, "payload too large"), ))), StatusCode::TOO_MANY_REQUESTS => { - let retry_after = res.headers()[header::RETRY_AFTER] - .to_str() - .unwrap() - .parse::() - .unwrap(); + // the header is expected here, but a missing or malformed one + // (an HTTP-date, or a proxy that stripped it) is not worth a + // panic — and must not come out as 0 either, which would tell a + // caller sleeping for this long to retry immediately + let retry_after = res + .headers() + .get(header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .filter(|secs| *secs > 0) + .unwrap_or(DEFAULT_RETRY_AFTER_SECS); Err(Error::with_value(ErrValue::TooManyRequests(retry_after))) } - StatusCode::OK | _ => { - let resp_data = res.json()?; - Ok(resp_data) + status if status.is_server_error() => Err(Error::with_value(ErrValue::ServerError( + status.as_u16(), + error_detail(res, status.canonical_reason().unwrap_or("server error")), + ))), + status if status.is_success() => { + // 204 responses (delete endpoints) and other empty bodies are + // deserialized from JSON `null`, so `serde_json::Value` and + // `Option` targets succeed instead of hitting a serde EOF + let body = res.text()?; + let body = body.trim(); + if body.is_empty() { + Ok(serde_json::from_str("null")?) + } else { + Ok(serde_json::from_str(body)?) + } } + // redirects and anything else we do not know about: reporting the + // status instead of feeding the body to the deserializer + status => Err(Error::with_value(ErrValue::Other(format!( + "unexpected response status {}: {}", + status, + error_detail(res, "empty response body"), + )))), + } + } +} + +/// Logs every `Warning` header returned by the API. +/// +/// APIv0.7 uses it to report non fatal problems with an otherwise successful +/// request, e.g. metadata keys dropped as invalid on `local_copy`. +fn log_warnings(res: &Response) { + for value in res.headers().get_all(header::WARNING).iter() { + match value.to_str() { + Ok(warning) => warn!("uploadcare api warning: {}", warning), + Err(_) => warn!("uploadcare api warning (non utf-8): {:?}", value.as_bytes()), } } } + +/// Builds a readable message out of an error response body. +/// +/// Most of the API errors come as `{"detail": "..."}`, but not all of them do: +/// 404/405 may carry an empty body and a 5xx may be an html page produced by an +/// intermediate proxy. Deserializing those is what used to surface a serde +/// error instead of the actual http one, so whatever does not look like the +/// known json payload is passed through as raw text. +fn error_detail(res: Response, fallback: &str) -> String { + match res.text() { + Ok(body) => detail_from_body(body.as_str(), fallback), + Err(_) => fallback.to_string(), + } +} + +/// The body parsing part of [`error_detail`], split out to be testable. +fn detail_from_body(body: &str, fallback: &str) -> String { + if let Ok(err) = serde_json::from_str::(body) { + return err.detail(); + } + + let body = body.trim(); + if body.is_empty() { + return fallback.to_string(); + } + if body.chars().count() > MAX_ERROR_BODY_LEN { + let mut cut: String = body.chars().take(MAX_ERROR_BODY_LEN).collect(); + cut.push_str("... (truncated)"); + return cut; + } + body.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_api_version_accept_header() { + assert_eq!(ApiVersion::V07.to_string(), "v0.7"); + } + + #[test] + fn test_detail_from_json_body() { + assert_eq!( + detail_from_body(r#"{"detail": "Method not allowed."}"#, "fallback"), + "Method not allowed.", + ); + } + + #[test] + fn test_detail_from_empty_body() { + // 404/405 responses may carry no body at all + assert_eq!(detail_from_body("", "not found"), "not found"); + assert_eq!(detail_from_body(" \n ", "not found"), "not found"); + } + + #[test] + fn test_detail_from_non_json_body() { + // proxy generated 5xx pages are not json, they must not end up as a + // serde error + assert_eq!( + detail_from_body("502 Bad Gateway", "server error"), + "502 Bad Gateway", + ); + } + + #[test] + fn test_detail_from_json_without_detail_field() { + assert_eq!( + detail_from_body(r#"{"error": "oops"}"#, "bad request"), + r#"{"error": "oops"}"#, + ); + } + + #[test] + fn test_detail_is_truncated() { + let detail = detail_from_body("x".repeat(MAX_ERROR_BODY_LEN + 100).as_str(), "fallback"); + + assert_eq!(detail.len(), MAX_ERROR_BODY_LEN + "... (truncated)".len()); + assert!(detail.ends_with("... (truncated)")); + } +} diff --git a/src/ucare/upload/mod.rs b/src/ucare/upload/mod.rs index 4921148..ab3ec0c 100644 --- a/src/ucare/upload/mod.rs +++ b/src/ucare/upload/mod.rs @@ -111,6 +111,9 @@ impl Client { StatusCode::BAD_REQUEST => Err(Error::with_value(ErrValue::BadRequest( res.text_with_charset("utf-8")?, ))), + StatusCode::UNAUTHORIZED => Err(Error::with_value(ErrValue::Unauthorized( + res.text_with_charset("utf-8")?, + ))), StatusCode::FORBIDDEN => Err(Error::with_value(ErrValue::Forbidden( res.text_with_charset("utf-8")?, ))), @@ -120,18 +123,34 @@ impl Client { StatusCode::PAYLOAD_TOO_LARGE => Err(Error::with_value(ErrValue::PayloadTooLarge( res.text_with_charset("utf-8")?, ))), - // picking 30 seconds because retry-after is not returned from the API - StatusCode::TOO_MANY_REQUESTS => Err(Error::with_value(ErrValue::TooManyRequests(30))), - StatusCode::OK | _ => match res.json() { - Ok(data) => Ok(data), - Err(err) => { - if err.to_string().contains("EOF") { - Ok(R::default()) - } else { - Err(Error::from(err)) - } + StatusCode::TOO_MANY_REQUESTS => { + // the Upload API usually omits Retry-After; default to 30s then + let retry_after = res + .headers() + .get(header::RETRY_AFTER) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + .unwrap_or(30); + Err(Error::with_value(ErrValue::TooManyRequests(retry_after))) + } + status if status.is_server_error() => Err(Error::with_value(ErrValue::ServerError( + status.as_u16(), + res.text_with_charset("utf-8")?, + ))), + status if status.is_success() => { + // some endpoints answer with an empty body on success + let body = res.text_with_charset("utf-8")?; + if body.trim().is_empty() { + Ok(R::default()) + } else { + Ok(serde_json::from_str(body.trim())?) } - }, + } + status => Err(Error::with_value(ErrValue::Other(format!( + "unexpected response status {}: {}", + status, + res.text_with_charset("utf-8")?, + )))), } } } diff --git a/src/upload.rs b/src/upload.rs index 157ca93..72e4612 100644 --- a/src/upload.rs +++ b/src/upload.rs @@ -9,11 +9,11 @@ //! the Uploadcare API endpoints. There are two basic upload types: //! //! - Direct uploads, a regular upload mode that suits most files less than 100MB -//! in size. You won’t be able to use this mode for larger files. +//! in size. You won’t be able to use this mode for larger files. //! //! - Multipart uploads, a more sophisticated upload mode supporting any files -//! larger than 10MB and implementing accelerated uploads through -//! a distributed network. +//! larger than 10MB and implementing accelerated uploads through +//! a distributed network. use std::collections::HashMap; use std::fmt::{self, Debug, Display}; @@ -21,8 +21,8 @@ use std::fmt::{self, Debug, Display}; use reqwest::{blocking::multipart::Form, Method, Url}; use serde::Deserialize; -use crate::file::{ImageInfo, VideoInfo}; -use crate::ucare::{upload::Client, upload::Fields, upload::Payload, Result}; +use crate::types::ImageInfo; +use crate::ucare::{upload::Client, upload::Fields, upload::Payload, ErrValue, Error, Result}; /// Service is used to make calls to file API. pub struct Service<'a> { @@ -30,7 +30,7 @@ pub struct Service<'a> { } /// creates new upload service instance -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } @@ -38,22 +38,16 @@ impl Service<'_> { /// Uploads a file and return its unique id (uuid). Comply with the RFC7578 standard. /// Resulting HashMap holds filenames as keys and their ids are values. pub fn file(&self, params: FileParams) -> Result> { - let mut form = Form::new() - .file(params.name.to_string(), params.path.to_string())? - .text( - "UPLOADCARE_STORE", - if let Some(val) = params.to_store { - val - } else { - ToStore::False - } - .to_string(), - ); + let mut form = Form::new().file(params.name, params.path)?; + if let Some(val) = params.to_store { + form = form.text("UPLOADCARE_STORE", val.to_string()); + } + form = add_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::>( Method::POST, - format!("/base/"), + "/base/".to_string(), None, Some(Payload::Form(form)), ) @@ -61,15 +55,10 @@ impl Service<'_> { /// Uploads file by its public URL. pub fn from_url(&self, params: FromUrlParams) -> Result { - let mut form = Form::new().text("source_url", params.source_url).text( - "store", - if let Some(val) = params.to_store { - val - } else { - ToStore::False - } - .to_string(), - ); + let mut form = Form::new().text("source_url", params.source_url); + if let Some(val) = params.to_store { + form = form.text("store", val.to_string()); + } if let Some(val) = params.filename { form = form.text("filename", val); } @@ -79,11 +68,12 @@ impl Service<'_> { if let Some(val) = params.save_url_duplicates { form = form.text("save_URL_duplicates", val.to_string()); } + form = add_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( Method::POST, - format!("/from_url/"), + "/from_url/".to_string(), None, Some(Payload::Form(form)), ) @@ -130,7 +120,7 @@ impl Service<'_> { self.client.call::( Method::POST, - format!("/group/"), + "/group/".to_string(), None, Some(Payload::Form(form)), ) @@ -162,31 +152,29 @@ impl Service<'_> { pub fn multipart_start(&self, params: MultipartParams) -> Result { let mut form = Form::new() .text("filename", params.filename) - .text( - "UPLOADCARE_STORE", - if let Some(val) = params.to_store { - val - } else { - ToStore::False - } - .to_string(), - ) .text("content_type", params.content_type) .text("size", params.size.to_string()); + if let Some(val) = params.to_store { + form = form.text("UPLOADCARE_STORE", val.to_string()); + } + if let Some(val) = params.part_size { + form = form.text("part_size", val.to_string()); + } + form = add_metadata_tags(form, params.metadata, params.tags)?; form = add_signature_expire(&(*self.client.auth_fields)(), form); self.client.call::( Method::POST, - format!("/multipart/start/"), + "/multipart/start/".to_string(), None, Some(Payload::Form(form)), ) } /// The second phase is about uploading file parts to the provided URLs. Each uploaded part - /// should be 5MB (5242880 bytes) in size except for the last one that can be smaller. You - /// can upload file parts in parallel provided the byte order stays unchanged. Make sure to - /// define Content-Type header for your data. + /// MUST be exactly the part size chosen at [`Service::multipart_start`] (the API default is + /// 5242880 bytes, see [`MultipartParams::part_size`]), except for the last one that can be + /// smaller. You can upload file parts in parallel provided the byte order stays unchanged. pub fn upload_part(&self, url: &str, data: Vec) -> Result<()> { self.client .call_url::<()>(Method::PUT, Url::parse(url)?, Some(Payload::Raw(data))) @@ -199,7 +187,7 @@ impl Service<'_> { self.client.call::( Method::POST, - format!("/multipart/complete/"), + "/multipart/complete/".to_string(), None, Some(Payload::Form(form)), ) @@ -218,15 +206,37 @@ pub struct FileParams { pub path: String, /// Uploaded file name pub name: String, - /// File storing behaviour. + /// File storing behaviour. Left to the API default when None. pub to_store: Option, + /// Arbitrary metadata to attach to the file, sent as `metadata[key]` fields. + /// + /// Keys are limited to 64 characters of `a-z A-Z 0-9 _ - . :` — same as the + /// file metadata of the REST API — and a file can hold up to 50 of them; keys + /// with other characters are ignored by the API. Values are non empty strings + /// of up to 512 characters: numbers, booleans and nested objects cannot be + /// stored. + pub metadata: HashMap, + /// Tags to attach to the file. + /// + /// Up to 50 of them, each up to 100 characters of lowercase latin letters, + /// digits, `-`, `_` and `.`. The API lowercases, trims and deduplicates them, so + /// what comes back may differ from what was sent; this crate passes the values + /// through as they are rather than normalizing locally. + /// + /// A tag outside that charset is not dropped: it fails the whole upload with a + /// `400`. The one exception handled client side is a `,`, which is the separator + /// of the wire format and would otherwise split one tag into two. + /// + /// An empty vector is treated the same as `None` and sends no field at all. + pub tags: Option>, } /// Parameters for upload from public URL link +#[derive(Default)] pub struct FromUrlParams { /// File URL, which should be a public HTTP or HTTPS link pub source_url: String, - /// File storing behaviour. + /// File storing behaviour. Left to the API default when None. pub to_store: Option, /// The name for a file uploaded from URL. If not defined, the filename is obtained from /// either response headers or a source URL @@ -237,16 +247,32 @@ pub struct FromUrlParams { /// `source_url` will be used more than once. If you don’t explicitly defined, it is by /// default set to the value of `check_url_duplicates`. pub save_url_duplicates: Option, + /// Arbitrary metadata to attach to the file, sent as `metadata[key]` fields. + /// See [`FileParams::metadata`]. + pub metadata: HashMap, + /// Tags to attach to the file. See [`FileParams::tags`]. + /// + /// Attached to the file the fetch produces, so they are only readable once the + /// upload has finished — through [`Service::from_url_status`], or right away in + /// the [`FromUrlData::FileInfo`] answer of a `check_URL_duplicates` hit. + pub tags: Option>, } /// Holds data returned by `from_url` +/// +/// Discriminated by the `type` field of the response: `token` for an accepted +/// asynchronous upload, `file_info` when `check_URL_duplicates` found the file +/// already uploaded and answered with it right away. +// the size difference between the variants is accepted: boxing FileInfo would +// complicate every caller for the sake of a short lived response value +#[allow(clippy::large_enum_variant)] #[derive(Debug, Deserialize)] -#[serde(untagged)] +#[serde(tag = "type")] pub enum FromUrlData { - /// Token + /// The upload was accepted, poll [`Service::from_url_status`] with the token. #[serde(rename = "token")] Token(FileToken), - /// File info + /// The file was already known, no new upload took place. #[serde(rename = "file_info")] FileInfo(FileInfo), } @@ -260,16 +286,13 @@ impl Default for FromUrlData { /// Respose for the `FromUrlData::Token` #[derive(Debug, Deserialize, Default)] pub struct FileToken { - /// Value: "token" - #[serde(rename = "type")] - pub data_type: String, /// A token to identify a file for the upload status request - #[serde(skip_serializing_if = "Option::is_none")] - pub token: Option, + pub token: String, } /// Holds the response returned by `from_url_status` -#[derive(Debug, Deserialize)] +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Default, Deserialize)] #[serde(tag = "status")] pub enum FromUrlStatusData { /// Success @@ -279,17 +302,20 @@ pub enum FromUrlStatusData { #[serde(rename = "progress")] Progress { /// Currently uploaded file size in bytes - done: u32, - /// Total file size in bytes - total: u32, + done: u64, + /// Total file size in bytes, `None` while it is not known yet + total: Option, }, /// File upload error #[serde(rename = "error")] Error { /// Error description error: String, + /// Machine readable error code, `RequestThrottledError` for example + error_code: Option, }, /// Unknown + #[default] #[serde(rename = "unknown")] Unknown, /// Waiting @@ -297,25 +323,19 @@ pub enum FromUrlStatusData { Waiting, } -impl Default for FromUrlStatusData { - fn default() -> Self { - FromUrlStatusData::Unknown - } -} - /// Holds file information in the upload context #[derive(Debug, Deserialize, Default)] pub struct FileInfo { /// True if file is stored pub is_stored: bool, /// Denotes currently uploaded file size in bytes - pub done: u32, + pub done: u64, /// Same as uuid pub file_id: String, /// Total is same as size - pub total: u32, + pub total: u64, /// File size in bytes - pub size: u32, + pub size: u64, /// File UUID pub uuid: String, /// If file is an image @@ -337,6 +357,71 @@ pub struct FileInfo { pub s3_bucket: Option, /// CDN media transformations applied to the file when its group was created pub default_effects: Option, + /// Recognized content information, same shape as in the REST API v0.7. + pub content_info: Option, + /// Arbitrary user defined `key -> value` pairs attached to the file. + /// + /// An empty map both when the field is missing and when it is an explicit + /// `null` — the latter is what webhook deliveries carry, and this struct is + /// close enough to their payload to be reused for one. + #[serde(default, deserialize_with = "crate::ucare::de_null_as_default")] + pub metadata: HashMap, +} + +/// Video related information as returned by the Upload API. +/// +/// Not to be confused with [`crate::file::VideoInfo`]: the Upload API is versioned +/// separately from the REST API and keeps the pre-v0.7 shape, where `audio` and +/// `video` are single objects rather than lists of streams. +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoInfo { + /// Video duration in milliseconds. + /// + /// Documented as an integer, but the value is ffprobe derived and a + /// fractional one has been observed, so both parse; see + /// [`crate::ucare::de_lenient_int`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub duration: Option, + /// Video format (MP4 for example). + pub format: Option, + /// Video bitrate. Same leniency as `duration`. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Audio information + pub audio: Option, + /// Video stream info + pub video: Option, +} + +/// Information about the audio in video +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoInfoAudio { + /// Audio stream metadata. Same leniency as [`VideoInfo::duration`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Audio stream codec. + pub codec: Option, + /// Audio stream sample rate. + pub sample_rate: Option, + /// Audio stream number of channels. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub channels: Option, +} + +/// Video stream info +#[derive(Debug, PartialEq, Deserialize)] +pub struct VideoInfoVideo { + /// Video stream image height. + pub height: Option, + /// Video stream image width. + pub width: Option, + /// Video stream frame rate. May be fractional (NTSC's `29.97`). + pub frame_rate: Option, + /// Video stream bitrate. Same leniency as [`VideoInfo::duration`]. + #[serde(default, deserialize_with = "crate::ucare::de_lenient_int")] + pub bitrate: Option, + /// Video stream codec. + pub codec: Option, } /// Group information @@ -351,8 +436,9 @@ pub struct GroupInfo { pub file_count: u32, /// CDN URL of the group pub cdn_url: String, - /// Files list - pub files: Option>, + /// Files list. An element is `None` when the corresponding file has been + /// removed. + pub files: Option>>, /// Group API url to get this info pub url: String, /// Group ID @@ -365,11 +451,25 @@ pub struct MultipartParams { /// Original file name pub filename: String, /// Precise file size in bytes. Should not exceed your project file size cap. - pub size: u32, + pub size: u64, /// A file MIME-type pub content_type: String, - /// File storing behaviour. + /// File storing behaviour. Left to the API default when None. pub to_store: Option, + /// Expected size of a single part in bytes. + /// + /// Left to the API default of 5242880 (5 MiB) when None. Worth raising for files + /// over a gigabyte, otherwise the part count — and with it the number of + /// presigned urls in the response — grows into the thousands. + /// + /// Whatever is chosen here decides how [`Service::upload_part`] has to slice the + /// file: every part but the last one MUST be exactly this size. + pub part_size: Option, + /// Arbitrary metadata to attach to the file, sent as `metadata[key]` fields. + /// See [`FileParams::metadata`]. + pub metadata: HashMap, + /// Tags to attach to the file. See [`FileParams::tags`]. + pub tags: Option>, } /// Response for starting multipart upload @@ -410,6 +510,11 @@ impl Display for UploadStatus { } /// Sets the file storing behaviour +/// +/// Leaving the parameter unset on the params structs sends no field at all, which +/// lets the API apply its own default. That default is `Auto` for projects registered +/// after February 12, 2024 and `False` for the older ones, so it is worth being +/// explicit whenever the behaviour matters. pub enum ToStore { /// True True, @@ -456,16 +561,297 @@ impl Display for UrlDuplicates { } } +/// Adds the file metadata and tags fields to an upload form. +/// +/// Shared by the direct and the multipart upload, both of which accept them in +/// exactly the same shape. +fn add_metadata_tags( + mut form: Form, + metadata: HashMap, + tags: Option>, +) -> Result
{ + for (key, value) in metadata { + form = form.text(metadata_field(key.as_str()), value); + } + if let Some(value) = encode_tags(tags)? { + form = form.text("tags", value); + } + + Ok(form) +} + +/// Builds the form field name for a metadata key. +fn metadata_field(key: &str) -> String { + format!("metadata[{}]", key) +} + +/// Encodes tags the way the API expects them: one comma separated field rather than +/// a repeated one. `None` when there is nothing to send. +/// +/// A tag holding a `,` is rejected instead of being sent: the separator has no +/// escape, so such a value would silently arrive as two tags — while the same +/// value passed to `file::Service::set_tags` travels as a json array element and +/// stays one. The comma is outside the documented tag charset anyway. +fn encode_tags(tags: Option>) -> Result> { + let tags = match tags { + None => return Ok(None), + Some(tags) if tags.is_empty() => return Ok(None), + Some(tags) => tags, + }; + + if let Some(tag) = tags.iter().find(|tag| tag.contains(',')) { + return Err(Error::with_value(ErrValue::BadRequest(format!( + "invalid tag {:?}: `,` separates the tags of an upload request and \ + is not part of the documented tag charset", + tag, + )))); + } + + Ok(Some(tags.join(","))) +} + fn add_signature_expire(auth_fields: &Fields, form: Form) -> Form { + // each endpoint documents exactly one of the two key field names + // (`UPLOADCARE_PUB_KEY` for base/multipart, `pub_key` for from_url/group); + // both are always sent and the endpoint picks its own let form = form .text("UPLOADCARE_PUB_KEY", auth_fields.pub_key.to_string()) .text("pub_key", auth_fields.pub_key.to_string()); - if let None = auth_fields.signature { - return form; - } - form.text( - "signature", - auth_fields.signature.as_ref().unwrap().to_string(), - ) - .text("expire", auth_fields.expire.as_ref().unwrap().to_string()) + + match (auth_fields.signature.as_ref(), auth_fields.expire.as_ref()) { + (Some(signature), Some(expire)) => form + .text("signature", signature.to_string()) + .text("expire", expire.to_string()), + _ => form, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_field_names() { + // the documented key charset is a-z A-Z 0-9 `_-.:`, up to 64 characters; + // keys outside of it are ignored by the API. The value is passed through + // as given, the brackets are all we add. + assert_eq!(metadata_field("subsystem"), "metadata[subsystem]"); + assert_eq!(metadata_field("a-b.c:d_9"), "metadata[a-b.c:d_9]"); + } + + #[test] + fn from_url_data_is_discriminated_by_type() { + let token: FromUrlData = + serde_json::from_str(r#"{"type": "token", "token": "945ebb27-1fd6-46c6"}"#).unwrap(); + match token { + FromUrlData::Token(data) => assert_eq!(data.token, "945ebb27-1fd6-46c6"), + FromUrlData::FileInfo(_) => panic!("a token response parsed as file_info"), + } + + // check_URL_duplicates hit: the file is returned right away + let info: FromUrlData = serde_json::from_str( + r#"{ + "type": "file_info", + "uuid": "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", + "file_id": "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6", + "is_stored": true, + "is_image": false, + "is_ready": true, + "done": 100, + "total": 100, + "size": 100, + "filename": "test.txt", + "original_filename": "test.txt", + "mime_type": "text/plain", + "metadata": {} + }"#, + ) + .unwrap(); + match info { + FromUrlData::FileInfo(data) => { + assert_eq!(data.uuid, "1f067f79-cbc8-4b61-9c7b-1c1e0ea6b4b6") + } + FromUrlData::Token(_) => panic!("a file_info response parsed as token"), + } + } + + #[test] + fn from_url_status_progress_total_may_be_null() { + let status: FromUrlStatusData = + serde_json::from_str(r#"{"status": "progress", "done": 50, "total": null}"#).unwrap(); + + match status { + FromUrlStatusData::Progress { done, total } => { + assert_eq!(done, 50); + assert_eq!(total, None); + } + _ => panic!("expected the progress variant"), + } + } + + #[test] + fn from_url_status_error_carries_the_code() { + let status: FromUrlStatusData = serde_json::from_str( + r#"{"status": "error", "error": "Host does not exist.", "error_code": "HostDoesNotExistError"}"#, + ) + .unwrap(); + + match status { + FromUrlStatusData::Error { error, error_code } => { + assert_eq!(error, "Host does not exist."); + assert_eq!(error_code, Some("HostDoesNotExistError".to_string())); + } + _ => panic!("expected the error variant"), + } + } + + #[test] + fn video_info_parses_the_documented_shape() { + let info: VideoInfo = serde_json::from_str( + r#"{ + "duration": 10000, + "format": "MP4", + "bitrate": 1000, + "audio": {"bitrate": 128, "codec": "aac", "sample_rate": 44100, "channels": 2}, + "video": {"height": 480, "width": 720, "frame_rate": 29.97, "bitrate": 900, "codec": "h264"} + }"#, + ) + .unwrap(); + + assert_eq!(info.audio.unwrap().channels, Some(2)); + assert_eq!(info.video.unwrap().frame_rate, Some(29.97)); + } + + #[test] + fn video_info_audio_channels_may_be_a_string() { + // what a real multipart_complete answers with: the schema says integer, + // the service sends a string + let info: VideoInfo = serde_json::from_str( + r#"{ + "duration": 10000, + "format": "MP4", + "bitrate": 1000, + "audio": {"bitrate": 128, "codec": "aac", "sample_rate": 44100, "channels": "2"} + }"#, + ) + .unwrap(); + + assert_eq!(info.audio.unwrap().channels, Some(2)); + } + + #[test] + fn video_info_audio_channels_may_be_absent_or_null() { + let absent: VideoInfoAudio = serde_json::from_str(r#"{"codec": "aac"}"#).unwrap(); + assert_eq!(absent.channels, None); + + let null: VideoInfoAudio = + serde_json::from_str(r#"{"codec": "aac", "channels": null}"#).unwrap(); + assert_eq!(null.channels, None); + } + + #[test] + fn video_info_audio_channels_rejects_a_non_numeric_string() { + let err = serde_json::from_str::(r#"{"channels": "stereo"}"#).unwrap_err(); + + assert!( + err.to_string().contains("stereo"), + "the offending value should be in the message, got {}", + err, + ); + } + + #[test] + fn tags_are_comma_separated() { + assert_eq!( + encode_tags(Some(vec!["invoice".to_string(), "2026".to_string()])).unwrap(), + Some("invoice,2026".to_string()), + ); + assert_eq!( + encode_tags(Some(vec!["invoice".to_string()])).unwrap(), + Some("invoice".to_string()), + ); + } + + #[test] + fn tags_send_no_field_when_there_is_nothing_to_send() { + assert_eq!(encode_tags(None).unwrap(), None); + assert_eq!(encode_tags(Some(vec![])).unwrap(), None); + } + + #[test] + fn tags_are_passed_through_unnormalized() { + // the API lowercases, trims and deduplicates; doing it here too would only + // make the crate disagree with the service on the details + assert_eq!( + encode_tags(Some(vec!["Invoice".to_string(), "invoice".to_string()])).unwrap(), + Some("Invoice,invoice".to_string()), + ); + } + + #[test] + fn tag_holding_the_separator_is_rejected() { + // it would arrive as two tags instead, while the same value sent through + // the REST API stays a single one + let err = encode_tags(Some(vec!["a,b".to_string()])).unwrap_err(); + + assert!( + err.to_string().contains("a,b"), + "the offending tag should be in the message, got {}", + err, + ); + } + + #[test] + fn video_info_numbers_may_be_fractional() { + // the schema documents integers, ffprobe derived values are not always + // whole; a single one of them must not fail the whole response + let info: VideoInfo = serde_json::from_str( + r#"{"duration": 22990.5, "format": "MP4", "bitrate": 1000.4, + "audio": {"bitrate": 128.5, "codec": "aac"}}"#, + ) + .unwrap(); + + assert_eq!(info.duration, Some(22991)); + assert_eq!(info.bitrate, Some(1000)); + assert_eq!(info.audio.unwrap().bitrate, Some(129)); + } + + #[test] + fn file_info_metadata_may_be_null() { + // what a webhook delivery carries; REST v0.7 always sends an object + let info: FileInfo = serde_json::from_str( + r#"{"is_stored": true, "done": 1, "file_id": "x", "total": 1, "size": 1, + "uuid": "x", "is_image": false, "filename": "a.txt", "is_ready": true, + "original_filename": "a.txt", "mime_type": "text/plain", + "metadata": null}"#, + ) + .unwrap(); + + assert!(info.metadata.is_empty()); + } + + #[test] + fn file_params_default_carries_no_metadata_or_tags() { + let params = FileParams::default(); + + assert!(params.metadata.is_empty()); + assert_eq!(params.tags, None); + } + + #[test] + fn from_url_params_default_carries_no_metadata_or_tags() { + let params = FromUrlParams::default(); + + assert!(params.metadata.is_empty()); + assert_eq!(params.tags, None); + } + + #[test] + fn multipart_params_default_leaves_part_size_to_the_api() { + let params = MultipartParams::default(); + + assert_eq!(params.part_size, None); + assert!(params.metadata.is_empty()); + assert_eq!(params.tags, None); + } } diff --git a/src/webhook.rs b/src/webhook.rs index 5489c34..53fbe41 100644 --- a/src/webhook.rs +++ b/src/webhook.rs @@ -1,6 +1,21 @@ //! Holds all primitives and logic around the webhook resource. +//! +//! Two independent notions of version are involved here, do not mix them up: +//! +//! - the **request** version, the `Accept` header configured on the client. It decides +//! which subscription versions the API accepts for this request. +//! - the **subscription** version, [`Info::version`]. It decides which events can be +//! subscribed to and in which format deliveries arrive at the target url. +//! +//! The subscription version is fixed when the subscription is created and can never be +//! changed afterwards; the only way to "change" it is deleting the subscription and +//! creating it anew. Reading the list of subscriptions can therefore return a mix of +//! versions, which is why [`Info::version`] is a plain string rather than an enum. +//! +//! Since this crate speaks APIv0.7 only, [`Version::V07`] is the sole value it can +//! create subscriptions with, and [`CreateParams`] always sends it explicitly. -use std::fmt::Debug; +use std::fmt::{self, Debug, Display}; use reqwest::Method; use serde::{Deserialize, Serialize}; @@ -13,33 +28,75 @@ pub struct Service<'a> { } /// creates an instance of the webhook service -pub fn new_svc(client: &Client) -> Service { +pub fn new_svc(client: &Client) -> Service<'_> { Service { client } } impl Service<'_> { /// Returns a list of project webhooks + /// + /// May well contain subscriptions of different versions: the ones created before + /// the project moved to APIv0.7 keep theirs. + /// + /// Note that webhook availability is checked on every request to this resource, + /// including this one, and the check takes the target url into account. So a + /// `ErrValue::Forbidden` here or on one particular [`Service::create`] call says + /// nothing about the other addresses — do not cache it as a per project flag. pub fn list(&self) -> Result { self.client - .call::(Method::GET, format!("/webhooks/"), None, None) + .call::(Method::GET, "/webhooks/".to_string(), None, None) + } + + /// Returns a single webhook by its id + /// + /// Note: `GET /webhooks/{id}/` is not part of the documented contract (the + /// docs list exactly four webhook operations). It works, but being + /// undocumented it comes with no compatibility promise — when that matters, + /// use [`Service::list`] and filter. + pub fn get(&self, id: i32) -> Result { + self.client.call::( + Method::GET, + format!("/webhooks/{}/", id), + None, + None, + ) } /// Create and subscribe to webhook + /// + /// The `event` + `target_url` + project triple is unique. Subscribing twice + /// answers `400` with `This project is already subscribed on this event`, which + /// usually means "already subscribed" rather than a real failure. The same + /// `target_url` may serve several different events without conflict. + /// + /// `target_url` is validated at request time: `http` and `https` only, absolute, + /// with a host that has to resolve to a non private address. Loopback and + /// private range addresses are rejected, so a local endpoint cannot be used for + /// debugging — use a publicly reachable address or a tunnel. pub fn create(&self, mut params: CreateParams) -> Result { - if params.is_active.is_none() { - params.is_active = Some(true); + if params.version.is_none() { + params.version = Some(Version::V07); } let json = encode_json(¶ms)?; self.client.call::, Info>( Method::POST, - format!("/webhooks/"), + "/webhooks/".to_string(), None, Some(json), ) } /// Update webhook attributes. + /// + /// The update is partial, only the fields that are set are sent. The subscription + /// version is deliberately absent from [`UpdateParams`]: changing it is rejected + /// by the API with `WebHook version updates are not allowed.` + /// + /// The typical use is re-enabling a subscription the API disabled on its own: + /// after repeated delivery failures it sets `is_active` to false and notifies the + /// account billing address, and `is_active: Some(true)` here brings it back. + /// Events missed while it was off are not replayed. pub fn update(&self, params: UpdateParams) -> Result { let json = encode_json(¶ms)?; @@ -51,23 +108,26 @@ impl Service<'_> { ) } - /// Unsubscribe and delete webhook. + /// Unsubscribe from a target url. + /// + /// Removes **every** subscription pointing at this `target_url`, across all of the + /// events at once — it is not a way to drop a single event. To remove one + /// subscription of several sharing an address, there is no dedicated endpoint; + /// this call takes all of them. + /// + /// Unsubscribing from an address that has no subscriptions is not an error. pub fn delete(&self, params: DeleteParams) -> Result<()> { let json = encode_json(¶ms)?; - let res = self.client.call::, String>( + // the body has to travel with a DELETE here, which is unusual enough that + // some http clients drop it; reqwest attaches it regardless of the method, + // and a body-less request would be answered with `\`target_url\` is missing` + self.client.call::, ()>( Method::DELETE, - format!("/webhooks/unsubscribe/"), + "/webhooks/unsubscribe/".to_string(), None, Some(json), - ); - if let Err(err) = res { - if !err.to_string().contains("EOF") { - return Err(err); - } - } - - Ok(()) + ) } } @@ -75,24 +135,61 @@ impl Service<'_> { pub type List = Vec; /// Webhook information -#[derive(Deserialize, Debug)] +#[derive(Deserialize)] pub struct Info { - /// Webhook ID + /// Webhook ID. An integer, not a UUID, unlike file and group identifiers. pub id: i32, /// Webhook creation date-time pub created: String, /// Webhook update date-time pub updated: String, /// Webhook event + /// + /// A plain string on purpose: reading existing subscriptions can turn up an event + /// this version of the crate does not know about yet, and that must not break + /// deserialization. See [`Event`] for the values that can be subscribed to. pub event: String, /// Where webhook data will be POSTed pub target_url: String, - /// Webhook payload signing secret - pub signing_secret: String, - /// Webhook project ID + /// Webhook payload signing secret, if one was set + /// + /// This is a value the client chooses, and the API returns it in responses. Treat + /// it as a secret: the [`Debug`] implementation of this struct masks it, but + /// anything that reads the field directly has to take care of that itself. + pub signing_secret: Option, + /// Webhook project ID. Set by the API from the request credentials. pub project: i32, /// Whether it is active + /// + /// The API may clear this on its own after repeated delivery failures — that is + /// the usual reason for deliveries to stop arriving. The subscription itself is + /// kept, so [`Service::update`] with `is_active: Some(true)` re-enables it. pub is_active: bool, + /// Subscription version, `0.7` for everything this crate creates + /// + /// Fixed at creation time and never changes, so older subscriptions keep + /// reporting the version they were made with. A plain string for that reason. + pub version: String, +} + +impl Debug for Info { + /// Masks `signing_secret` so it does not end up in logs or debug output. + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.debug_struct("Info") + .field("id", &self.id) + .field("created", &self.created) + .field("updated", &self.updated) + .field("event", &self.event) + .field("target_url", &self.target_url) + .field( + "signing_secret", + &self.signing_secret.as_ref().map(|_| ""), + ) + .field("project", &self.project) + .field("is_active", &self.is_active) + .field("version", &self.version) + .finish() + } } /// Params for creating webhook @@ -104,24 +201,80 @@ pub struct CreateParams { /// unique for each project — event type combination. pub target_url: String, /// Payload can be signed with a secret to ensure that the request comes from the expected - /// sender. Leave None if you don't want to change it + /// sender. Optional, not sent when `None`. + #[serde(skip_serializing_if = "Option::is_none")] pub signing_secret: Option, - /// Marks a subscription as either active or not, defaults to true, otherwise false. + /// Marks a subscription as either active or not. Not sent when `None`, the + /// API default is true. + #[serde(skip_serializing_if = "Option::is_none")] pub is_active: Option, + /// Subscription version. Defaults to [`Version::V07`] when left None. + /// + /// Always sent explicitly so that the created subscription does not silently + /// depend on which `Accept` version the client happens to send. + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +/// Version of a webhook subscription +/// +/// Only `0.7` can be created through APIv0.7: passing `0.6` is answered with `400` +/// `Invalid version`. Older subscriptions keep their own version — read it from +/// [`Info::version`], which is a plain string for that reason. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +#[non_exhaustive] +pub enum Version { + /// "0.7" + #[serde(rename = "0.7")] + V07, +} + +impl Display for Version { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match *self { + Version::V07 => write!(f, "0.7"), + } + } } /// Events to subscribe for -#[derive(Debug, Serialize)] +/// +/// All of these require a `0.7` subscription. A `0.6` one only supports +/// [`Event::FileUploaded`], but since this crate cannot create anything but `0.7` +/// subscriptions, that combination is not reachable through it. +#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize)] +#[non_exhaustive] pub enum Event { /// Fires when file is uploaded #[serde(rename = "file.uploaded")] FileUploaded, + /// Fires when a threat is found in a file by the virus scan. Added in APIv0.7. + #[serde(rename = "file.infected")] + FileInfected, + /// Fires when a file is moved to permanent storage. Added in APIv0.7. + #[serde(rename = "file.stored")] + FileStored, + /// Fires when a file is marked as removed. Added in APIv0.7. + #[serde(rename = "file.deleted")] + FileDeleted, + /// Fires when data accompanying a file changes, but not its content: metadata, + /// tags or application results. Added in APIv0.7. + /// + /// Carries the list of changed attribute groups together with their previous + /// values. Two things to keep in mind: no event is published when nothing + /// actually changed, so this is not a confirmation that a write happened; and + /// application results change as a consequence of background processing that may + /// well not have been requested by your code. + #[serde(rename = "file.info_updated")] + FileInfoUpdated, } /// Params for updating webhook #[derive(Debug, Serialize)] pub struct UpdateParams { - /// Webhook ID + /// Webhook ID. Identifies the subscription in the request path; not part + /// of the documented request body, hence never serialized into it. + #[serde(skip_serializing)] pub id: i32, /// An event you subscribe to. Leave None if you don't want to change it #[serde(skip_serializing_if = "Option::is_none")] @@ -132,7 +285,9 @@ pub struct UpdateParams { #[serde(skip_serializing_if = "Option::is_none")] pub target_url: Option, /// Payload can be signed with a secret to ensure that the request comes from the expected - /// sender + /// sender. Leave it `None` to keep the current secret: the field is only + /// sent when set, so an unset secret can never wipe an existing one. + #[serde(skip_serializing_if = "Option::is_none")] pub signing_secret: Option, /// Marks a subscription as either active or not, leave it None if you don't want to change it. #[serde(skip_serializing_if = "Option::is_none")] @@ -142,6 +297,187 @@ pub struct UpdateParams { /// Params for deleting webhook #[derive(Debug, Serialize)] pub struct DeleteParams { - /// Webhook will be found and deleted by its target_url + /// Every subscription pointing at this target_url will be removed, for all of the + /// events at once pub target_url: String, } + +#[cfg(test)] +mod tests { + use super::*; + + fn info_json() -> &'static str { + r#"{ + "id": 1387, + "created": "2026-08-04T10:00:00.123456Z", + "updated": "2026-08-04T10:00:00.123456Z", + "event": "file.info_updated", + "target_url": "https://example.com/uploadcare/hook", + "project": 13, + "is_active": true, + "signing_secret": "s3cr3t", + "version": "0.7" + }"# + } + + #[test] + fn info_deserializes() { + let info: Info = serde_json::from_str(info_json()).unwrap(); + + assert_eq!(info.id, 1387); + assert_eq!(info.version, "0.7"); + assert_eq!(info.event, "file.info_updated"); + assert_eq!(info.signing_secret, Some("s3cr3t".to_string())); + } + + #[test] + fn info_accepts_null_signing_secret() { + let json = + info_json().replace(r#""signing_secret": "s3cr3t""#, r#""signing_secret": null"#); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.signing_secret, None); + } + + #[test] + fn info_accepts_older_subscription_versions() { + // the list can hold a mix: subscriptions created before the move to v0.7 + // keep their own version and their own delivery format + let json = info_json() + .replace(r#""version": "0.7""#, r#""version": "0.6""#) + .replace( + r#""event": "file.info_updated""#, + r#""event": "file.uploaded""#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.version, "0.6"); + } + + #[test] + fn info_accepts_unknown_event() { + // event is a plain string so a value this version does not know about does + // not break reading the list + let json = info_json().replace( + r#""event": "file.info_updated""#, + r#""event": "file.something_new""#, + ); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert_eq!(info.event, "file.something_new"); + } + + #[test] + fn info_debug_masks_signing_secret() { + let info: Info = serde_json::from_str(info_json()).unwrap(); + let debug = format!("{:?}", info); + + assert!(!debug.contains("s3cr3t"), "secret leaked into {}", debug); + assert!(debug.contains("")); + // the rest is still there to debug with + assert!(debug.contains("1387")); + assert!(debug.contains("https://example.com/uploadcare/hook")); + } + + #[test] + fn info_debug_keeps_absent_secret_absent() { + let json = + info_json().replace(r#""signing_secret": "s3cr3t""#, r#""signing_secret": null"#); + let info: Info = serde_json::from_str(json.as_str()).unwrap(); + + assert!(format!("{:?}", info).contains("signing_secret: None")); + } + + #[test] + fn create_params_serialize_new_events() { + let params = CreateParams { + event: Event::FileInfoUpdated, + target_url: "https://example.com/uploadcare/hook".to_string(), + signing_secret: Some("s3cr3t".to_string()), + is_active: Some(true), + version: Some(Version::V07), + }; + + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({ + "event": "file.info_updated", + "target_url": "https://example.com/uploadcare/hook", + "signing_secret": "s3cr3t", + "is_active": true, + "version": "0.7", + }), + ); + } + + #[test] + fn create_params_event_names() { + let name = |event: Event| { + serde_json::to_value(CreateParams { + event, + target_url: "https://example.com/hook".to_string(), + signing_secret: None, + is_active: None, + version: None, + }) + .unwrap()["event"] + .clone() + }; + + assert_eq!( + name(Event::FileUploaded), + serde_json::json!("file.uploaded") + ); + assert_eq!( + name(Event::FileInfected), + serde_json::json!("file.infected") + ); + assert_eq!(name(Event::FileStored), serde_json::json!("file.stored")); + assert_eq!(name(Event::FileDeleted), serde_json::json!("file.deleted")); + assert_eq!( + name(Event::FileInfoUpdated), + serde_json::json!("file.info_updated"), + ); + } + + #[test] + fn update_params_cannot_carry_a_version() { + // changing the subscription version is answered with 400, so the field is + // absent from UpdateParams altogether; this pins that down + let params = UpdateParams { + id: 1387, + event: None, + target_url: None, + signing_secret: None, + is_active: Some(true), + }; + let value = serde_json::to_value(¶ms).unwrap(); + + assert!(value.get("version").is_none()); + // partial update: only the set fields travel. In particular an unset + // signing_secret must not be sent as null (that risks wiping the + // stored secret), and `id` belongs to the request path, not the body. + assert_eq!(value, serde_json::json!({"is_active": true})); + } + + #[test] + fn create_params_omit_unset_fields() { + let params = CreateParams { + event: Event::FileUploaded, + target_url: "https://example.com/hook".to_string(), + signing_secret: None, + is_active: None, + version: Some(Version::V07), + }; + + // unset optional fields are not sent as nulls, the API defaults apply + assert_eq!( + serde_json::to_value(¶ms).unwrap(), + serde_json::json!({ + "event": "file.uploaded", + "target_url": "https://example.com/hook", + "version": "0.7", + }), + ); + } +} diff --git a/tests/rest.rs b/tests/rest.rs index 3602879..48d1637 100644 --- a/tests/rest.rs +++ b/tests/rest.rs @@ -3,40 +3,33 @@ use rand::Rng; -use ucare::{self, conversion, file, group, project, webhook}; +use ucare::{self, addons, conversion, file, group, project, webhook}; mod testenv; -fn rest_client_version(version: ucare::RestApiVersion) -> ucare::RestClient { +fn rest_client() -> ucare::RestClient { let config = ucare::RestConfig { sign_based_auth: true, - api_version: version, + api_version: ucare::RestApiVersion::V07, }; ucare::RestClient::new(config, testenv::api_creds()).unwrap() } -fn rest_client_v05() -> ucare::RestClient { - rest_client_version(ucare::RestApiVersion::V05) -} - -fn rest_client_v06() -> ucare::RestClient { - rest_client_version(ucare::RestApiVersion::V06) -} - #[test] fn file() { - let client = rest_client_v05(); + let client = rest_client(); let file_svc = file::new_svc(&client); let limit = 13; let params = file::ListParams { - removed: Some(false), - stored: Some(false), + removed: Some(file::Filter::False), + stored: Some(file::Filter::All), limit: Some(3), - ordering: Some(file::Ordering::Size), + ordering: Some(file::Ordering::DatetimeUploaded), from: None, + include: None, }; // file list @@ -60,7 +53,7 @@ fn file() { // file info let first_file = files.pop().unwrap(); - let file = file_svc.info(&first_file.uuid).unwrap(); + let file = file_svc.info(&first_file.uuid, None).unwrap(); assert_eq!(file.uuid, first_file.uuid); @@ -69,6 +62,41 @@ fn file() { assert_ne!(info.datetime_stored, None); + // metadata: set, read one, read all, delete + let value = file_svc + .set_metadata_value(&file.uuid, "subsystem", "sdk-test") + .unwrap(); + assert_eq!(value, "sdk-test"); + assert_eq!( + file_svc.metadata_value(&file.uuid, "subsystem").unwrap(), + "sdk-test", + ); + assert_eq!( + file_svc.metadata(&file.uuid).unwrap().get("subsystem"), + Some(&"sdk-test".to_string()), + ); + file_svc + .delete_metadata_value(&file.uuid, "subsystem") + .unwrap(); + assert_eq!(file_svc.metadata(&file.uuid).unwrap().get("subsystem"), None); + + // tags: add through both endpoints, then remove + let tags = file_svc.set_tags(&file.uuid, &["sdk-test"]).unwrap(); + assert!(tags.tags.contains(&"sdk-test".to_string())); + let tags = file_svc + .update_tags(&file.uuid, &["sdk-test-extra"], &[]) + .unwrap(); + assert!(tags.tags.contains(&"sdk-test-extra".to_string())); + let tags = file_svc + .update_tags(&file.uuid, &[], &["sdk-test", "sdk-test-extra"]) + .unwrap(); + assert!(!tags.tags.contains(&"sdk-test".to_string())); + assert_eq!( + file_svc.tags(&file.uuid).unwrap().tags, + tags.tags, + "GET /tags/ must agree with the PATCH response", + ); + // batch store let batch_info = file_svc.batch_store(&[&files.pop().unwrap().uuid]).unwrap(); @@ -77,15 +105,16 @@ fn file() { None ); - // file copy + // file copy: POST /files/ is 405 since v0.7, local_copy is the replacement let params = file::CopyParams { source: file.uuid.to_string(), store: None, - make_public: Some(file::MakePublic::True), + metadata: None, + make_public: None, target: None, pattern: None, }; - let copy_info = file_svc.copy(params).unwrap(); + let copy_info = file_svc.local_copy(params).unwrap(); assert_eq!(copy_info.result.original_filename, file.original_filename); @@ -95,9 +124,55 @@ fn file() { assert_ne!(deleted.datetime_removed, None); } +#[test] +fn search() { + let client = rest_client(); + let file_svc = file::new_svc(&client); + + // taking any existing file to look for it by an exact uuid match: that path + // bypasses the search index, so there is no lag to wait for + let params = file::ListParams { + removed: Some(file::Filter::False), + stored: Some(file::Filter::All), + limit: Some(1), + ordering: Some(file::Ordering::DatetimeUploaded), + from: None, + include: None, + }; + let existing = file_svc + .list(params) + .unwrap() + .results + .unwrap() + .pop() + .unwrap(); + + let params = file::SearchParams { + query: file::SearchQuery { + exact: Some(file::Exact { + uuid: Some(vec![existing.uuid.to_string()]), + ..Default::default() + }), + ..Default::default() + }, + limit: Some(1), + offset: None, + include: None, + }; + let found = file_svc.search(params).unwrap(); + + let results = found.results.unwrap(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].info.uuid, existing.uuid); + + // no criteria at all is a 400 + let params = file::SearchParams::default(); + assert!(file_svc.search(params).is_err()); +} + #[test] fn group() { - let client = rest_client_v06(); + let client = rest_client(); let group_svc = group::new_svc(&client); let limit = 3; @@ -133,23 +208,36 @@ fn group() { #[test] fn conversion() { - let client = rest_client_v06(); + let client = rest_client(); let file_svc = file::new_svc(&client); let conv_svc = conversion::new_svc(&client); let params = file::ListParams { - removed: Some(false), - stored: Some(false), + removed: Some(file::Filter::False), + stored: Some(file::Filter::All), limit: Some(1), - ordering: Some(file::Ordering::Size), + ordering: Some(file::Ordering::DatetimeUploaded), from: None, + include: None, }; let list = file_svc.list(params).unwrap(); // convert file + let source = list.results.unwrap().pop().unwrap().uuid; + + // what this file can be converted to; must parse regardless of whether the + // file is convertible at all + let doc_info = conv_svc.document_info(&source).unwrap(); + println!( + "document_info: error={:?}, groups={:?}", + doc_info.error, + doc_info.any_converted_groups(), + ); + let params = conversion::JobParams { - paths: vec![list.results.unwrap().pop().unwrap().uuid + "/document/-/format/pdf/"], + paths: vec![source + "/document/-/format/pdf/"], store: Some(conversion::ToStore::False), + save_in_group: None, }; let job_result = conv_svc.document(params).unwrap(); if let Some(mut jobs) = job_result.result { @@ -163,34 +251,103 @@ fn conversion() { } } +#[test] +fn addon() { + let client = rest_client(); + let file_svc = file::new_svc(&client); + let addons_svc = addons::new_svc(&client); + + let params = file::ListParams { + removed: Some(file::Filter::False), + stored: Some(file::Filter::All), + limit: Some(1), + ordering: Some(file::Ordering::DatetimeUploaded), + from: None, + include: None, + }; + let target = file_svc + .list(params) + .unwrap() + .results + .unwrap() + .pop() + .unwrap(); + + let request_id = match addons_svc.execute( + "uc_clamav_virus_scan", + addons::ExecuteParams::new(&target.uuid), + ) { + Ok(execution) => execution.request_id, + Err(err) => match err.value() { + // the add-on may be disabled for the project (a per application + // permission) or already busy with this very file; neither says + // the client is wrong + ucare::ErrValue::Forbidden(_) | ucare::ErrValue::Conflict(_) => return, + other => panic!("virus scan failed to start: {}", other), + }, + }; + assert!(!request_id.is_empty()); + + // freshly started: any status is a pass, the point is that it parses + let info = addons_svc + .status("uc_clamav_virus_scan", &request_id) + .unwrap(); + println!("virus scan status: {:?}", info.status); +} + #[test] fn webhook() { let sign_secret = "test_signing_secret"; let new_sign_secret = "new_signing_secret"; - let client = rest_client_v06(); + let client = rest_client(); let webhook_svc = webhook::new_svc(&client); - // list - let list = webhook_svc.list().unwrap(); - assert!(list.len() > 0); - assert_ne!(list.get(0).unwrap().id, 0); - // create + // + // the host has to resolve to a non private address, so localhost is not an + // option here: v0.7 rejects it at request validation time. + // the suffix is wide enough for collisions with leftovers of previously + // crashed runs to be negligible let mut rng = rand::thread_rng(); - let suff: u8 = rng.gen(); - let target_url = format!("https://localhost:8080/test_endpoint{}", suff); + let suff: u32 = rng.gen(); + let target_url = format!("https://example.com/test_endpoint{}", suff); let create_params = webhook::CreateParams { - event: webhook::Event::FileUploaded, + event: webhook::Event::FileInfoUpdated, target_url: target_url.clone(), signing_secret: Some(sign_secret.to_string()), is_active: None, + version: None, }; let hook = webhook_svc.create(create_params).unwrap(); assert!(hook.is_active); - assert!(hook.created.len() > 0); - assert!(hook.updated.len() > 0); - assert_eq!(hook.signing_secret, sign_secret); + assert!(!hook.created.is_empty()); + assert!(!hook.updated.is_empty()); + assert_eq!(hook.signing_secret, Some(sign_secret.to_string())); + // created without an explicit version, still has to end up on 0.7 + assert_eq!(hook.version, "0.7"); + + // list: now that at least one subscription exists, ours must be in it + let list = webhook_svc.list().unwrap(); + assert!(list.iter().any(|h| h.id == hook.id)); + + // get by id + let fetched = webhook_svc.get(hook.id).unwrap(); + assert_eq!(fetched.id, hook.id); + assert_eq!(fetched.target_url, target_url); + + // subscribing to the same event and url again is a recognizable 400 + let duplicate = webhook_svc.create(webhook::CreateParams { + event: webhook::Event::FileInfoUpdated, + target_url: target_url.clone(), + signing_secret: None, + is_active: None, + version: None, + }); + match duplicate { + Err(err) => assert!(err.to_string().contains("already subscribed")), + Ok(_) => panic!("duplicate subscription was accepted"), + } // update let update_params = webhook::UpdateParams { @@ -202,17 +359,30 @@ fn webhook() { }; let hook = webhook_svc.update(update_params).unwrap(); assert!(!hook.is_active); - assert_eq!(hook.signing_secret, new_sign_secret); + assert_eq!(hook.signing_secret, Some(new_sign_secret.to_string())); + + // re-enabling a disabled subscription; the update is partial, so the + // signing secret set by the previous update has to survive it + let hook = webhook_svc + .update(webhook::UpdateParams { + id: hook.id, + event: None, + target_url: None, + signing_secret: None, + is_active: Some(true), + }) + .unwrap(); + assert!(hook.is_active); + assert_eq!(hook.signing_secret, Some(new_sign_secret.to_string())); - // delete + // delete: takes every subscription on that url, with a body on a DELETE request let delete_params = webhook::DeleteParams { target_url }; - let res = webhook_svc.delete(delete_params).unwrap(); - assert_eq!(res, ()); + webhook_svc.delete(delete_params).unwrap(); } #[test] fn project() { - let client = rest_client_v06(); + let client = rest_client(); let project_svc = project::new_svc(&client); // info diff --git a/tests/testenv/mod.rs b/tests/testenv/mod.rs index 9b72832..2b202bc 100644 --- a/tests/testenv/mod.rs +++ b/tests/testenv/mod.rs @@ -1,7 +1,5 @@ use std::env; -use ucare; - pub fn api_creds() -> ucare::ApiCreds { let secret_key = env::var("UCARE_SECRET_KEY").unwrap(); let pub_key = env::var("UCARE_PUBLIC_KEY").unwrap(); diff --git a/tests/upload.rs b/tests/upload.rs index de2f9be..d9bcb83 100644 --- a/tests/upload.rs +++ b/tests/upload.rs @@ -1,4 +1,5 @@ use rand::Rng; +use std::collections::HashMap; use std::fs; use std::io::Read; @@ -25,10 +26,15 @@ fn file_and_group() { let filename = "London_is_the_capital_of_great_britain_".to_string() + suff.to_string().as_str(); + let mut metadata = HashMap::new(); + metadata.insert("subsystem".to_string(), "integration-test".to_string()); + let params = upload::FileParams { path: "./tests/test_image.jpg".to_string(), name: filename.to_string(), to_store: Some(upload::ToStore::True), + metadata, + tags: Some(vec!["integration".to_string(), "rust".to_string()]), }; let short_file_info = upload_svc.file(params).unwrap(); @@ -61,16 +67,16 @@ fn from_url() { filename: Some("Great_London".to_string()), check_url_duplicates: None, save_url_duplicates: None, + metadata: HashMap::new(), + tags: Some(vec!["integration".to_string(), "rust".to_string()]), }; let data = upload_svc.from_url(params).unwrap(); match data { upload::FromUrlData::Token(val) => { - assert_ne!(val.token, None); + assert!(!val.token.is_empty()); // check status - let status_data = upload_svc - .from_url_status(val.token.unwrap().as_str()) - .unwrap(); + let status_data = upload_svc.from_url_status(val.token.as_str()).unwrap(); println!("{:?}", status_data); } upload::FromUrlData::FileInfo(info) => { @@ -94,6 +100,10 @@ fn multipart() { size: 10_905_778, content_type: "image/jpeg".to_string(), to_store: None, + // the local chunker below cuts at 5 MiB, which is what the API defaults to + part_size: None, + metadata: HashMap::new(), + tags: None, }; let multipart_data = upload_svc.multipart_start(params).unwrap();